@shaferllc/keel 0.66.0 → 0.74.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/AGENTS.md +169 -0
- package/README.md +44 -6
- package/bin/keel-mcp.mjs +9 -0
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +8 -2
- package/dist/core/cache.d.ts +81 -5
- package/dist/core/cache.js +188 -23
- package/dist/core/cli/stubs.d.ts +26 -0
- package/dist/core/cli/stubs.js +225 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +39 -13
- package/dist/core/index.js +19 -6
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +89 -4
- package/dist/core/logger.js +167 -22
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +325 -14
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +299 -7
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +3206 -0
- package/docs/ai.md +128 -0
- package/docs/api-resources.md +118 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +385 -0
- package/docs/console.md +536 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +631 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +121 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +208 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +166 -0
- package/docs/examples/mail.ts +263 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +215 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/i18n.md +302 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +436 -0
- package/docs/mail.md +751 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +452 -0
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +314 -0
- package/docs/testing.md +376 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +19816 -0
- package/llms.txt +127 -0
- package/package.json +42 -7
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# Authentication
|
|
2
|
+
|
|
3
|
+
Session-based auth built on the pieces you already have: [sessions](./sessions.md)
|
|
4
|
+
hold the login, [hashing](./hashing.md) checks passwords. `auth()` ties them
|
|
5
|
+
together.
|
|
6
|
+
|
|
7
|
+
Requires [`sessionMiddleware()`](./sessions.md) in your HTTP kernel — every
|
|
8
|
+
`auth()` call reaches through the session, so without the middleware the first
|
|
9
|
+
call throws `Session is not available…`.
|
|
10
|
+
|
|
11
|
+
## Tell Keel how to load a user
|
|
12
|
+
|
|
13
|
+
Register a **user provider** once (in a service provider) — a function that
|
|
14
|
+
returns a user for an id. Keel stays database-agnostic, so this is wherever your
|
|
15
|
+
users live:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { setUserProvider } from "@shaferllc/keel/core";
|
|
19
|
+
|
|
20
|
+
setUserProvider((id) => db.users.find(id));
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The id handed to your provider is always a **string** — `login()` normalizes
|
|
24
|
+
whatever you pass (number or string) with `String(id)` before stashing it. If
|
|
25
|
+
your ids are numeric, coerce inside the provider (`db.users.find(Number(id))`).
|
|
26
|
+
|
|
27
|
+
## Logging in
|
|
28
|
+
|
|
29
|
+
Verify the password yourself with `hash`, then `login()` the user's id:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { auth, hash, response } from "@shaferllc/keel/core";
|
|
33
|
+
|
|
34
|
+
async login() {
|
|
35
|
+
const { email, password } = await request.only(["email", "password"]);
|
|
36
|
+
const user = await db.users.findByEmail(email);
|
|
37
|
+
|
|
38
|
+
if (!user || !(await hash.verify(user.password, password))) {
|
|
39
|
+
return response.abort("Invalid credentials", 401);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
auth().login(user.id);
|
|
43
|
+
return response.redirect("/dashboard");
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`login()` only writes the id to the session — it does no lookup and no password
|
|
48
|
+
check. Verifying credentials is your job (above); `login()` is the "trust this id
|
|
49
|
+
from now on" step.
|
|
50
|
+
|
|
51
|
+
**Verify in constant time.** The snippet above skips the password check when no
|
|
52
|
+
user is found, so a missing account answers faster than a wrong password — a
|
|
53
|
+
timing signal that leaks which emails are registered. Compare against `hash.dummy`
|
|
54
|
+
(a valid hash that never matches) so both paths cost the same:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const user = await db.users.findByEmail(email);
|
|
58
|
+
const ok = await hash.verify(user?.password ?? hash.dummy, password);
|
|
59
|
+
if (ok && user) auth().login(user.id); // `user &&` so the dummy never logs anyone in
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Reading the current user
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
auth().check(); // is someone logged in?
|
|
66
|
+
auth().guest(); // …or not?
|
|
67
|
+
auth().id(); // the user id (string), or null
|
|
68
|
+
await auth().user(); // the full user (via your provider), or null
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`user()` reads the id from the session and runs it back through your provider on
|
|
72
|
+
every call — there's no request-level cache, so if you need it twice in one
|
|
73
|
+
handler, hold onto the result. Type the row it returns with the generic:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
type User = { id: number; email: string };
|
|
77
|
+
|
|
78
|
+
const user = await auth().user<User>(); // User | null
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`user()` returns `null` when nobody is logged in. But if someone *is* logged in
|
|
82
|
+
and you never called `setUserProvider`, it throws — Keel has no way to turn the
|
|
83
|
+
id back into a user:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
Error: No user provider. Call setUserProvider((id) => findUser(id)).
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Logging out
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
auth().logout();
|
|
93
|
+
return response.redirect("/");
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`logout()` forgets the id from the session; it doesn't destroy the whole session,
|
|
97
|
+
so anything else you flashed or stored survives. Clear the session yourself if
|
|
98
|
+
you want a clean slate on sign-out.
|
|
99
|
+
|
|
100
|
+
## Protecting routes
|
|
101
|
+
|
|
102
|
+
`authGuard()` rejects unauthenticated requests. Register it as
|
|
103
|
+
[named middleware](./middleware.md) and apply it wherever you need:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { authGuard } from "@shaferllc/keel/core";
|
|
107
|
+
|
|
108
|
+
router.named({ auth: authGuard({ redirectTo: "/login" }) });
|
|
109
|
+
|
|
110
|
+
router.get("/dashboard", [DashboardController, "index"]).use("auth");
|
|
111
|
+
router.group(() => { /* … */ }).use("auth");
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Without `redirectTo`, the guard returns `401 Unauthenticated` (ideal for APIs):
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
router.named({ auth: authGuard() }); // 401 JSON on failure, no redirect
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The guard only checks that *someone* is logged in — it runs no provider lookup
|
|
121
|
+
and loads no user. It gates on `guest()`, so it's cheap; load the user inside the
|
|
122
|
+
handler with `auth().user()` when you actually need it.
|
|
123
|
+
|
|
124
|
+
## Token (API) authentication
|
|
125
|
+
|
|
126
|
+
Sessions ride on a cookie — great for a server-rendered app, awkward for an SPA,
|
|
127
|
+
a mobile client, or another service. For those, issue a **stateless bearer
|
|
128
|
+
token**: an HS256 JWT signed with `config('app.key')`, built on the Web Crypto
|
|
129
|
+
API so it works the same on Node and the edge (no `jsonwebtoken`, no native
|
|
130
|
+
bindings). This is the Cloudflare-Workers-friendly path — nothing to store
|
|
131
|
+
server-side.
|
|
132
|
+
|
|
133
|
+
Issue a token in your login handler instead of (or alongside) `auth().login()`:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { jwt, hash, response } from "@shaferllc/keel/core";
|
|
137
|
+
|
|
138
|
+
async login() {
|
|
139
|
+
const { email, password } = await request.only(["email", "password"]);
|
|
140
|
+
const user = await db.users.findByEmail(email);
|
|
141
|
+
if (!user || !(await hash.verify(user.password, password))) {
|
|
142
|
+
return response.abort("Invalid credentials", 401);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const token = await jwt.sign({ sub: String(user.id) }, { expiresIn: "1h" });
|
|
146
|
+
return response.json({ token });
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Protect API routes with `bearerAuth()`. It reads `Authorization: Bearer <token>`,
|
|
151
|
+
verifies it, and makes the token's `sub` the authenticated id — so `auth()` works
|
|
152
|
+
downstream exactly as it does for sessions, provider lookup and all:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { bearerAuth, auth } from "@shaferllc/keel/core";
|
|
156
|
+
|
|
157
|
+
router.get("/api/me", async () => response.json(await auth().user())).use(bearerAuth());
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
A missing or invalid token gets `401 Unauthenticated`. Pass `{ optional: true }`
|
|
161
|
+
to let the request through unauthenticated (`auth().check()` is then `false`). A
|
|
162
|
+
token verified this way takes precedence over any session cookie on the same
|
|
163
|
+
request, and — unlike sessions — needs no session store, so `bearerAuth()` works
|
|
164
|
+
without `sessionMiddleware()`.
|
|
165
|
+
|
|
166
|
+
`jwt` is a standalone primitive if you need tokens outside the guard:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const token = await jwt.sign({ sub: "42", role: "admin" }, { expiresIn: "7d" });
|
|
170
|
+
const payload = await jwt.verify(token); // { sub, role, iat, exp } | null
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`verify()` returns `null` — never throws — for a token that's malformed,
|
|
174
|
+
tampered, expired, not-yet-valid, or fails an `issuer`/`audience` check. Only
|
|
175
|
+
HS256 is accepted: `alg: none` and asymmetric algorithms are refused, closing the
|
|
176
|
+
classic JWT algorithm-confusion hole. `sign()` accepts `expiresIn` (seconds, or a
|
|
177
|
+
duration string like `"30s"`, `"15m"`, `"1h"`, `"7d"`), plus `subject`, `issuer`,
|
|
178
|
+
`audience`, and a `secret` override.
|
|
179
|
+
|
|
180
|
+
## Opaque access tokens
|
|
181
|
+
|
|
182
|
+
A JWT is stateless — you can't revoke one without extra machinery. When you need
|
|
183
|
+
**revocable, scoped** API tokens (a "personal access tokens" screen, per-token
|
|
184
|
+
abilities, "log out this device"), use the database-backed token store instead. A
|
|
185
|
+
token is a row you can delete, so revocation is instant.
|
|
186
|
+
|
|
187
|
+
Store them in a `personal_access_tokens` table (all timestamps epoch-ms):
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
selector TEXT UNIQUE, hash TEXT, tokenable_id TEXT, name TEXT,
|
|
191
|
+
abilities TEXT, last_used_at INTEGER, expires_at INTEGER, created_at INTEGER
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Mint a token after verifying credentials — the plaintext is shown **once**:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { createToken } from "@shaferllc/keel/core";
|
|
198
|
+
|
|
199
|
+
const { token } = await createToken(user.id, {
|
|
200
|
+
abilities: ["posts:read", "posts:write"], // or ["*"] for everything
|
|
201
|
+
expiresIn: "30d", // omit for no expiry
|
|
202
|
+
name: "CLI token",
|
|
203
|
+
});
|
|
204
|
+
return response.json({ token }); // "keel_<selector>.<verifier>"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Protect routes with `tokenAuth()` — it verifies the `Bearer` token, makes its
|
|
208
|
+
owner the authenticated user, and can require abilities:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import { tokenAuth, auth, token, tokenCan } from "@shaferllc/keel/core";
|
|
212
|
+
|
|
213
|
+
router.get("/api/posts", async () => response.json(await auth().user()))
|
|
214
|
+
.use(tokenAuth({ abilities: ["posts:read"] }));
|
|
215
|
+
|
|
216
|
+
// inside a handler, inspect the verified token:
|
|
217
|
+
token(); // { tokenableId, abilities, expiresAt, … } | null
|
|
218
|
+
tokenCan("posts:write"); // boolean
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The token splits into a public **selector** (indexed, for lookup) and a secret
|
|
222
|
+
**verifier** (stored only as a SHA-256 hash), so a leaked database can't mint
|
|
223
|
+
working tokens — and verification needs no `RETURNING`, so it's portable across
|
|
224
|
+
every driver. Manage tokens with `listTokens(userId)`, `revokeToken(selector)`,
|
|
225
|
+
and `revokeTokens(userId)` (log out everywhere). Verifying an expired token
|
|
226
|
+
deletes it in passing, so the table self-prunes.
|
|
227
|
+
|
|
228
|
+
**JWT vs. opaque:** reach for `jwt` when you want zero-lookup, stateless tokens
|
|
229
|
+
(and don't need revocation); reach for `createToken`/`tokenAuth` when you need
|
|
230
|
+
revocation, per-token scopes, or last-used tracking.
|
|
231
|
+
|
|
232
|
+
## Basic authentication
|
|
233
|
+
|
|
234
|
+
For internal tools and quick gates, `basicAuth()` implements HTTP Basic auth —
|
|
235
|
+
the browser's native `username` / `password` prompt. Always behind HTTPS, since
|
|
236
|
+
the credentials ride on every request:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
import { basicAuth, auth, hash } from "@shaferllc/keel/core";
|
|
240
|
+
|
|
241
|
+
router.get("/admin", () => response.json(auth().id())).use(
|
|
242
|
+
basicAuth(async (username, password) => {
|
|
243
|
+
const user = await db.users.findByEmail(username);
|
|
244
|
+
const ok = await hash.verify(user?.password ?? hash.dummy, password);
|
|
245
|
+
return ok && user ? user.id : false; // return the id to log them in, or false
|
|
246
|
+
}, { realm: "Admin" }),
|
|
247
|
+
);
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The verifier returns the user's id (logs them in for the request), `true` (allow
|
|
251
|
+
without an identity), or a falsy value (reject). On rejection `basicAuth` answers
|
|
252
|
+
`401` with a `WWW-Authenticate` challenge so the browser re-prompts.
|
|
253
|
+
|
|
254
|
+
## Social sign-in
|
|
255
|
+
|
|
256
|
+
"Sign in with GitHub/Google/Discord" lives in its own guide —
|
|
257
|
+
[Social authentication](./social-auth.md).
|
|
258
|
+
|
|
259
|
+
## Registration
|
|
260
|
+
|
|
261
|
+
Registration is the same flow in reverse — hash the password on the way in:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
const user = await db.users.create({
|
|
265
|
+
email,
|
|
266
|
+
password: await hash.make(password),
|
|
267
|
+
});
|
|
268
|
+
auth().login(user.id);
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Working with `Auth` directly
|
|
272
|
+
|
|
273
|
+
`auth()` is a thin accessor — it returns a fresh, stateless `Auth` bound to the
|
|
274
|
+
current request's session. You can construct one yourself if you prefer; it reads
|
|
275
|
+
the same session, so the two are interchangeable:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import { Auth } from "@shaferllc/keel/core";
|
|
279
|
+
|
|
280
|
+
if (new Auth().check()) { /* … */ }
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
There's nothing to share between instances — all state lives in the session — so
|
|
284
|
+
`auth()` and `new Auth()` behave identically.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## API reference
|
|
289
|
+
|
|
290
|
+
### `auth()`
|
|
291
|
+
|
|
292
|
+
`auth(): Auth`
|
|
293
|
+
|
|
294
|
+
Returns an `Auth` accessor bound to the current request's session.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
import { auth } from "@shaferllc/keel/core";
|
|
298
|
+
|
|
299
|
+
auth().login(userId);
|
|
300
|
+
await auth().user();
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**Notes:** constructs a fresh `Auth` each call — it's stateless, so there's no
|
|
304
|
+
cost to calling it repeatedly. Every method underneath reaches through
|
|
305
|
+
`session()`, which throws if `sessionMiddleware()` isn't installed.
|
|
306
|
+
|
|
307
|
+
### `setUserProvider(fn)`
|
|
308
|
+
|
|
309
|
+
`setUserProvider(fn: UserProvider): void`
|
|
310
|
+
|
|
311
|
+
Registers the function Keel uses to turn a stored id back into a user.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
import { setUserProvider } from "@shaferllc/keel/core";
|
|
315
|
+
|
|
316
|
+
setUserProvider((id) => db.users.find(id));
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**Notes:** global — the last call wins. Register it once in a service provider.
|
|
320
|
+
Until it's set, `auth().user()` throws for a logged-in request (but still returns
|
|
321
|
+
`null` for a guest).
|
|
322
|
+
|
|
323
|
+
### `authGuard(options?)`
|
|
324
|
+
|
|
325
|
+
`authGuard(options?: { redirectTo?: string }): MiddlewareHandler`
|
|
326
|
+
|
|
327
|
+
Builds a middleware that blocks unauthenticated requests.
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
import { authGuard } from "@shaferllc/keel/core";
|
|
331
|
+
|
|
332
|
+
router.named({
|
|
333
|
+
auth: authGuard({ redirectTo: "/login" }),
|
|
334
|
+
api: authGuard(), // 401 instead
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
**Notes:** with `redirectTo`, guests get a redirect; without it, a
|
|
339
|
+
`401 { error: "Unauthenticated", status: 401 }` JSON response. Authenticated
|
|
340
|
+
requests pass straight through to the next handler. The check is `guest()` only —
|
|
341
|
+
no user is loaded.
|
|
342
|
+
|
|
343
|
+
### `Auth`
|
|
344
|
+
|
|
345
|
+
The accessor returned by `auth()`. Stateless — all its state lives in the
|
|
346
|
+
session — so you rarely construct it directly, though `new Auth()` works and is
|
|
347
|
+
equivalent to `auth()`.
|
|
348
|
+
|
|
349
|
+
#### `login(id)`
|
|
350
|
+
|
|
351
|
+
`login(id: string | number): void`
|
|
352
|
+
|
|
353
|
+
Marks the given id as the authenticated user by storing it in the session.
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
auth().login(user.id);
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**Notes:** does no lookup or password check — verify credentials before calling.
|
|
360
|
+
The id is coerced with `String(id)`, so `id()` and your provider always receive a
|
|
361
|
+
string.
|
|
362
|
+
|
|
363
|
+
#### `logout()`
|
|
364
|
+
|
|
365
|
+
`logout(): void`
|
|
366
|
+
|
|
367
|
+
Forgets the authenticated id from the session.
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
auth().logout();
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
**Notes:** only removes the auth key — other session data (flashes, cart, etc.)
|
|
374
|
+
survives. Call `session().clear()` yourself for a full reset.
|
|
375
|
+
|
|
376
|
+
#### `id()`
|
|
377
|
+
|
|
378
|
+
`id(): string | null`
|
|
379
|
+
|
|
380
|
+
The authenticated user's id, or `null` if nobody is logged in.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
const uid = auth().id(); // "42" | null
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**Notes:** always a string (see `login`). Returns `null`, not `undefined`, for a
|
|
387
|
+
guest.
|
|
388
|
+
|
|
389
|
+
#### `check()`
|
|
390
|
+
|
|
391
|
+
`check(): boolean`
|
|
392
|
+
|
|
393
|
+
`true` when a user is authenticated.
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
if (auth().check()) { /* logged in */ }
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**Notes:** a pure `id() != null` test — reads the session, runs no provider.
|
|
400
|
+
|
|
401
|
+
#### `guest()`
|
|
402
|
+
|
|
403
|
+
`guest(): boolean`
|
|
404
|
+
|
|
405
|
+
`true` when the request is unauthenticated — the inverse of `check()`.
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
if (auth().guest()) return response.redirect("/login");
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
#### `user(...)`
|
|
412
|
+
|
|
413
|
+
`user<User = unknown>(): Promise<User | null>`
|
|
414
|
+
|
|
415
|
+
Loads the full authenticated user by running the session id through the
|
|
416
|
+
registered provider.
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
type User = { id: number; email: string };
|
|
420
|
+
const user = await auth().user<User>(); // User | null
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
**Notes:** returns `null` when nobody is logged in. Throws
|
|
424
|
+
`No user provider…` if a user *is* logged in but `setUserProvider` was never
|
|
425
|
+
called. No caching — each call re-invokes the provider. The generic only types
|
|
426
|
+
the result; it does not validate the row's shape at runtime.
|
|
427
|
+
|
|
428
|
+
### Interfaces & types
|
|
429
|
+
|
|
430
|
+
#### `UserProvider`
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
type UserProvider = (id: string) => unknown | Promise<unknown>;
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
The seam between Keel and your user store. Implement it once and register it with
|
|
437
|
+
`setUserProvider` — Keel calls it with the string id from the session whenever
|
|
438
|
+
`auth().user()` runs, and treats the return value as the authenticated user.
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
import { setUserProvider, type UserProvider } from "@shaferllc/keel/core";
|
|
442
|
+
|
|
443
|
+
const provider: UserProvider = async (id) => {
|
|
444
|
+
// `id` is always a string; coerce if your keys are numeric
|
|
445
|
+
return db.users.find(Number(id));
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
setUserProvider(provider);
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
**Notes:** may be sync or async — `user()` awaits it either way. Return the user
|
|
452
|
+
object (any shape) when found, or a nullish value when not; that value flows back
|
|
453
|
+
out of `auth().user()`.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Authorization
|
|
2
|
+
|
|
3
|
+
Where [authentication](./authentication.md) answers *who you are*, authorization
|
|
4
|
+
answers *what you're allowed to do*. Keel gives you **gates** (ad-hoc abilities)
|
|
5
|
+
and **policies** (abilities grouped per model) — a compact authorization layer.
|
|
6
|
+
|
|
7
|
+
The current user is resolved from `auth().user()` by default, so authorization
|
|
8
|
+
composes with the session auth you already have.
|
|
9
|
+
|
|
10
|
+
## Gates
|
|
11
|
+
|
|
12
|
+
A gate is a named ability with a callback that receives the user and whatever you
|
|
13
|
+
pass to the check:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { define, can, authorize } from "@shaferllc/keel/core";
|
|
17
|
+
|
|
18
|
+
// register once, at boot (e.g. in a service provider):
|
|
19
|
+
define("update-post", (user, post) => post.authorId === user.id);
|
|
20
|
+
define("access-admin", (user) => user.role === "admin");
|
|
21
|
+
|
|
22
|
+
// check anywhere:
|
|
23
|
+
if (await can("update-post", post)) {
|
|
24
|
+
// …
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await authorize("update-post", post); // throws a 403 ForbiddenException if denied
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`can(ability, ...args)` returns a boolean; `cannot(...)` is its negation;
|
|
31
|
+
`authorize(...)` throws a `403` when denied (the HTTP kernel renders it).
|
|
32
|
+
|
|
33
|
+
## Policies
|
|
34
|
+
|
|
35
|
+
For a model with several abilities, group them in a **policy** class — one method
|
|
36
|
+
per ability — and register it. `can("update", post)` then routes to
|
|
37
|
+
`PostPolicy.update(user, post)` automatically, by the argument's class:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { policy, can } from "@shaferllc/keel/core";
|
|
41
|
+
|
|
42
|
+
class PostPolicy {
|
|
43
|
+
view(user, post) {
|
|
44
|
+
return post.published || post.authorId === user.id;
|
|
45
|
+
}
|
|
46
|
+
update(user, post) {
|
|
47
|
+
return post.authorId === user.id;
|
|
48
|
+
}
|
|
49
|
+
delete(user, post) {
|
|
50
|
+
return user.admin || post.authorId === user.id;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
policy(Post, PostPolicy); // register the class (or an instance)
|
|
55
|
+
|
|
56
|
+
await can("view", post); // → PostPolicy.view(user, post)
|
|
57
|
+
await authorize("delete", post); // → PostPolicy.delete(user, post) or 403
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A policy is a plain class — no base class, no framework glue. The method name is
|
|
61
|
+
the ability; the first argument to the check is the model.
|
|
62
|
+
|
|
63
|
+
## Admin bypass (before hooks)
|
|
64
|
+
|
|
65
|
+
Register a `gateBefore` callback to decide checks up front — return a boolean to
|
|
66
|
+
short-circuit, or `undefined` to fall through to the gate/policy. Perfect for a
|
|
67
|
+
super-admin:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { gateBefore } from "@shaferllc/keel/core";
|
|
71
|
+
|
|
72
|
+
gateBefore((user) => (user.role === "superadmin" ? true : undefined));
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A `gateAfter` callback brackets the other end: it runs *after* the gate/policy
|
|
76
|
+
and receives the result, returning a boolean to override it or `undefined` to keep
|
|
77
|
+
it. Use it to audit every decision, or to veto late:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { gateAfter } from "@shaferllc/keel/core";
|
|
81
|
+
|
|
82
|
+
gateAfter((user, ability, args, result) => {
|
|
83
|
+
log.info("authz", { user: user.id, ability, result });
|
|
84
|
+
return undefined; // keep the original decision
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## In a controller
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
export class PostController {
|
|
92
|
+
async update(c: Ctx) {
|
|
93
|
+
const post = await Post.findOrFail(param("id"));
|
|
94
|
+
await authorize("update", post); // 403 unless allowed
|
|
95
|
+
// … safe to proceed
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Checking a specific user
|
|
101
|
+
|
|
102
|
+
`can`/`authorize` use the current user. To check someone else (background jobs,
|
|
103
|
+
tests, impersonation), use the `For` variants:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { canFor, authorizeFor } from "@shaferllc/keel/core";
|
|
107
|
+
|
|
108
|
+
await canFor(otherUser, "update-post", post);
|
|
109
|
+
await authorizeFor(otherUser, "update-post", post);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Resolving the current user differently (token auth instead of session)?
|
|
113
|
+
`setUserResolver(() => currentUserSomehow())`.
|
|
114
|
+
|
|
115
|
+
## API reference
|
|
116
|
+
|
|
117
|
+
### `define(ability, callback)`
|
|
118
|
+
|
|
119
|
+
`define(ability: string, callback: (user, ...args) => boolean | Promise<boolean>): void`
|
|
120
|
+
|
|
121
|
+
Registers a gate. The callback receives the resolved user and the check
|
|
122
|
+
arguments.
|
|
123
|
+
|
|
124
|
+
### `policy(model, impl)`
|
|
125
|
+
|
|
126
|
+
`policy(model: Constructor, impl: Policy | (new () => Policy)): void`
|
|
127
|
+
|
|
128
|
+
Registers a policy (class or instance) for a model. `can(ability, instance)`
|
|
129
|
+
routes to `impl[ability](user, instance)` when the ability matches a method.
|
|
130
|
+
|
|
131
|
+
### `can(ability, ...args)` / `cannot(...)`
|
|
132
|
+
|
|
133
|
+
`can(ability: string, ...args): Promise<boolean>`
|
|
134
|
+
|
|
135
|
+
Whether the current user is allowed. `cannot` is the negation. Policy (matching
|
|
136
|
+
model argument) is tried first, then a gate; unknown abilities **deny**.
|
|
137
|
+
|
|
138
|
+
### `authorize(ability, ...args)`
|
|
139
|
+
|
|
140
|
+
`authorize(ability: string, ...args): Promise<void>`
|
|
141
|
+
|
|
142
|
+
Throws a `403` `ForbiddenException` unless allowed.
|
|
143
|
+
|
|
144
|
+
### `canFor(user, ...)` / `authorizeFor(user, ...)`
|
|
145
|
+
|
|
146
|
+
The `can` / `authorize` pair for an explicit user rather than the current one.
|
|
147
|
+
|
|
148
|
+
### `gateBefore(callback)`
|
|
149
|
+
|
|
150
|
+
`gateBefore(callback: (user, ability, args) => boolean | undefined | Promise<…>): void`
|
|
151
|
+
|
|
152
|
+
Runs before every check; a boolean short-circuits, `undefined` falls through.
|
|
153
|
+
|
|
154
|
+
### `setUserResolver(resolver)` / `clearAuthorization()`
|
|
155
|
+
|
|
156
|
+
Override how the current user is resolved (default `auth().user()`), and reset
|
|
157
|
+
all gates/policies/hooks (a test helper).
|
|
158
|
+
|
|
159
|
+
### Interfaces & types
|
|
160
|
+
|
|
161
|
+
#### `GateCallback`
|
|
162
|
+
|
|
163
|
+
`type GateCallback = (user: unknown, ...args: unknown[]) => boolean | Promise<boolean>`
|
|
164
|
+
|
|
165
|
+
#### `BeforeCallback`
|
|
166
|
+
|
|
167
|
+
`type BeforeCallback = (user, ability: string, args: unknown[]) => boolean | undefined | Promise<boolean | undefined>`
|