@pithy-sh/auth 0.1.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/LICENSE +21 -0
- package/README.md +46 -0
- package/docs/apple-signin.md +139 -0
- package/docs/facebook-oauth.md +92 -0
- package/docs/github-oauth.md +99 -0
- package/docs/google-oauth.md +118 -0
- package/package.json +58 -0
- package/pithy.manifest.json +108 -0
- package/src/admin/users.ts +357 -0
- package/src/audit/actions.ts +71 -0
- package/src/audit/emit.ts +223 -0
- package/src/capability.ts +300 -0
- package/src/client/api.ts +501 -0
- package/src/client/projection.ts +55 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/data/betterAuth.ts +210 -0
- package/src/data/device.ts +57 -0
- package/src/data/kitFields.ts +69 -0
- package/src/data/rotatedToken.ts +40 -0
- package/src/data/tables.ts +38 -0
- package/src/device/registry.ts +139 -0
- package/src/email/send.ts +67 -0
- package/src/http/adminRoutes.ts +368 -0
- package/src/http/baseUrl.ts +109 -0
- package/src/http/csrf.ts +98 -0
- package/src/http/devLoginRoute.ts +159 -0
- package/src/http/errors.ts +70 -0
- package/src/http/guards.ts +158 -0
- package/src/http/middleware.ts +67 -0
- package/src/http/rateLimit.ts +36 -0
- package/src/http/resolve.ts +152 -0
- package/src/http/responses.ts +199 -0
- package/src/http/routes.ts +325 -0
- package/src/http/schemas.ts +118 -0
- package/src/http/views.ts +93 -0
- package/src/i18n/errorCopy.es.ts +35 -0
- package/src/i18n/errorCopy.ts +99 -0
- package/src/index.ts +24 -0
- package/src/instance/auth.ts +309 -0
- package/src/instance/plugins.ts +172 -0
- package/src/instance/providers.ts +185 -0
- package/src/instance/secrets.ts +197 -0
- package/src/migrations/0001_init.ts +229 -0
- package/src/migrations/pluginTables.ts +334 -0
- package/src/seeds/devSession.ts +286 -0
- package/src/seeds/example.ts +48 -0
- package/src/test-utils/liveApp.ts +338 -0
- package/src/token/rotation.ts +104 -0
- package/src/version.generated.ts +16 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { zValidator } from "@hono/zod-validator";
|
|
5
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import type { ControlPlaneContext } from "@pithy-sh/core/src/controlPlane/context";
|
|
7
|
+
import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
|
|
8
|
+
import { MAX_PAGE_SIZE } from "@pithy-sh/core/src/data/cursor";
|
|
9
|
+
import { InternalError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
|
|
10
|
+
import { validationHook } from "@pithy-sh/core/src/http/validation";
|
|
11
|
+
import type { Context, Hono } from "hono";
|
|
12
|
+
import {
|
|
13
|
+
type AdminSubList,
|
|
14
|
+
findSessionById,
|
|
15
|
+
getUser,
|
|
16
|
+
listDeviceRegistry,
|
|
17
|
+
listUserDevices,
|
|
18
|
+
listUserSessions,
|
|
19
|
+
listUsers,
|
|
20
|
+
userProviders,
|
|
21
|
+
userSessionTokens,
|
|
22
|
+
} from "../admin/users";
|
|
23
|
+
import { AuthAuditActions } from "../audit/actions";
|
|
24
|
+
import { correlation, emitControlPlaneAction } from "../audit/emit";
|
|
25
|
+
import type { AuthWiring } from "../capability";
|
|
26
|
+
import { authDatabase } from "../data/tables";
|
|
27
|
+
import { deleteDevice, deviceSessionTokens } from "../device/registry";
|
|
28
|
+
import {
|
|
29
|
+
AUTH_DEVICES_READ_SCOPE,
|
|
30
|
+
AUTH_DEVICES_REVOKE_SCOPE,
|
|
31
|
+
AUTH_SESSIONS_REVOKE_SCOPE,
|
|
32
|
+
AUTH_USERS_LOGOUT_SCOPE,
|
|
33
|
+
AUTH_USERS_READ_SCOPE,
|
|
34
|
+
} from "./guards";
|
|
35
|
+
import { getAuthInstance, resolveDb } from "./resolve";
|
|
36
|
+
import type {
|
|
37
|
+
AdminDeviceRevokeResponse,
|
|
38
|
+
AdminDevicesResponse,
|
|
39
|
+
AdminRevokeResponse,
|
|
40
|
+
AdminUserResponse,
|
|
41
|
+
AdminUsersResponse,
|
|
42
|
+
} from "./responses";
|
|
43
|
+
import { ListDevicesQuery, ListUsersQuery, RevokeDeviceBody, RevokeSessionBody, UserIdParam } from "./schemas";
|
|
44
|
+
import { deviceView, sessionView, userView } from "./views";
|
|
45
|
+
|
|
46
|
+
type Ctx = Context<PithyHonoEnv>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The control-plane admin surface: the reads a management dashboard's user panes resolve to, and the
|
|
50
|
+
* revocations an incident costs.
|
|
51
|
+
*
|
|
52
|
+
* GET {base}/admin/users auth:users:read query: ListUsersQuery
|
|
53
|
+
* GET {base}/admin/users/:userId auth:users:read param: UserIdParam
|
|
54
|
+
* GET {base}/admin/devices auth:devices:read query: ListDevicesQuery
|
|
55
|
+
* POST {base}/admin/sessions/revoke auth:sessions:revoke json: RevokeSessionBody
|
|
56
|
+
* POST {base}/admin/users/:userId/sessions/revoke auth:users:logout param: UserIdParam
|
|
57
|
+
* POST {base}/admin/users/:userId/devices/revoke auth:devices:revoke param + json
|
|
58
|
+
*
|
|
59
|
+
* ## Registered before the catch-all, or dead
|
|
60
|
+
*
|
|
61
|
+
* `createAuthRoutes` ends with `app.all(`${base}/*`, handleBetterAuth)`, and `handleBetterAuth` returns
|
|
62
|
+
* a Response — which ends the chain. **Anything registered after that line is silently unreachable**,
|
|
63
|
+
* and a route-inspection test would still pass because the route is genuinely mounted; it just never
|
|
64
|
+
* runs. So these are registered from inside `createAuthRoutes` before the catch-all, and
|
|
65
|
+
* `routeContract.test.ts` proves it with a real request rather than by reading `app.routes`.
|
|
66
|
+
*
|
|
67
|
+
* ## `requireControlPlane` only, never `requireAuth`
|
|
68
|
+
*
|
|
69
|
+
* See `guards.ts` for the whole argument. The short version: the seam deliberately leaves `c.var.auth`
|
|
70
|
+
* null, so an auth gate here would deny every legitimate management call forever, and there is no user
|
|
71
|
+
* to sign in as that would fix it.
|
|
72
|
+
*
|
|
73
|
+
* ## Validators after the gate, on every line
|
|
74
|
+
*
|
|
75
|
+
* A validator ahead of the gate turns a 403 into a 400 and tells an unverified caller which requests
|
|
76
|
+
* were well-formed. On this surface that is a live oracle for an adopter's identity model, so the
|
|
77
|
+
* ordering is asserted in `routeContract.test.ts` rather than trusted to the order somebody typed the
|
|
78
|
+
* arguments in.
|
|
79
|
+
*
|
|
80
|
+
* ## Every response is a deliberate projection, and every projection has a schema
|
|
81
|
+
*
|
|
82
|
+
* No handler returns a row. The projections live in `views.ts` and the objects a client validates
|
|
83
|
+
* against live in `responses.ts`, each view typed as `z.output` of its own schema — so what this
|
|
84
|
+
* Worker sends and what a management client is told to expect are one declaration rather than two
|
|
85
|
+
* that drift. `sessionView` drops the session **token**; `deviceView` drops the **push token**; and
|
|
86
|
+
* the account link is read as a list of provider slugs by a query that selects only `providerId`, so
|
|
87
|
+
* the OAuth `accessToken`, `refreshToken` and `idToken` are never loaded at all.
|
|
88
|
+
*
|
|
89
|
+
* Each `c.json` below is `satisfies`-checked against its envelope. The check belongs at compile time:
|
|
90
|
+
* parsing every response would spend a validation pass on data this Worker just built from its own
|
|
91
|
+
* rows, and it would turn a shape mistake into a 500 in production rather than a red build.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/** The auth Kysely for this request. */
|
|
95
|
+
function db(c: Ctx, wiring: AuthWiring) {
|
|
96
|
+
return authDatabase(resolveDb(c.env, wiring.config.database));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One of the user pane's sub-reads, guarded and projected in one step (#380).
|
|
101
|
+
*
|
|
102
|
+
* **`try`/`catch` inside an `async` function, never `.catch()`.** The read is *called* inside the `try`,
|
|
103
|
+
* so a seam that throws before it returns a promise is caught too — a rejected promise is not the only
|
|
104
|
+
* way a D1 read fails, and a `.catch()` guard has been escaped by exactly that before (#371).
|
|
105
|
+
*
|
|
106
|
+
* **The guard takes no binding.** The state is the whole of what travels; what the read threw names a
|
|
107
|
+
* query and a table, and this response goes to a management client across a trust boundary.
|
|
108
|
+
*
|
|
109
|
+
* It projects while it is here, because the alternative is a second helper mapping a union it just
|
|
110
|
+
* built, and a projection that runs outside the guard is a second place a row can throw.
|
|
111
|
+
*/
|
|
112
|
+
async function readBounded<T, V>(
|
|
113
|
+
read: () => Promise<AdminSubList<T>>,
|
|
114
|
+
project: (row: T) => V,
|
|
115
|
+
): Promise<{ state: "read"; items: V[]; truncated: boolean } | { state: "unavailable" }> {
|
|
116
|
+
try {
|
|
117
|
+
const list = await read();
|
|
118
|
+
return { state: "read", items: list.items.map(project), truncated: list.truncated };
|
|
119
|
+
} catch {
|
|
120
|
+
return { state: "unavailable" };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The same guard for a read with no bound to exceed — the provider slugs, which carry no truncation. */
|
|
125
|
+
async function readWhole<T>(
|
|
126
|
+
read: () => Promise<T[]>,
|
|
127
|
+
): Promise<{ state: "read"; items: T[] } | { state: "unavailable" }> {
|
|
128
|
+
try {
|
|
129
|
+
return { state: "read", items: await read() };
|
|
130
|
+
} catch {
|
|
131
|
+
return { state: "unavailable" };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The verified management client behind a control-plane call.
|
|
137
|
+
*
|
|
138
|
+
* `requireControlPlane()` has run on every route below, so a null context is a wiring mistake rather
|
|
139
|
+
* than an unverified request — hence `InternalError`, not a 401. Deliberately not read off `c.var.auth`:
|
|
140
|
+
* a management client has no user row and no session, and keeping the two accessors apart is what stops
|
|
141
|
+
* a control-plane caller from being recorded as, or mistaken for, a user of this app.
|
|
142
|
+
*/
|
|
143
|
+
function controlPlaneCaller(c: Ctx): ControlPlaneContext {
|
|
144
|
+
const caller = c.var.controlPlane;
|
|
145
|
+
if (!caller) {
|
|
146
|
+
throw new InternalError({
|
|
147
|
+
message: "Authentication is misconfigured.",
|
|
148
|
+
detail: "requireControlPlane() must run before an auth admin handler reads the management caller.",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return caller;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The request correlation every admin audit event carries. */
|
|
155
|
+
function context(c: Ctx): { ip?: string; userAgent?: string; requestId?: string } {
|
|
156
|
+
return { ...correlation(c.req.raw.headers), requestId: c.req.header("cf-ray") };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Delete sessions through Better Auth's own adapter, so its bookkeeping stays consistent. */
|
|
160
|
+
async function revokeTokens(c: Ctx, wiring: AuthWiring, tokens: readonly string[]): Promise<number> {
|
|
161
|
+
if (tokens.length === 0) return 0;
|
|
162
|
+
// `internalAdapter.deleteSession`, never a raw DELETE — the same path `revokeMyDevice` and the
|
|
163
|
+
// reuse-detection family revoke take. A direct delete would leave whatever Better Auth keeps beside
|
|
164
|
+
// the row (secondary storage, its own caches) pointing at a session that no longer exists.
|
|
165
|
+
const ctx = await (await getAuthInstance(c, wiring)).$context;
|
|
166
|
+
for (const token of tokens) {
|
|
167
|
+
await ctx.internalAdapter.deleteSession(token);
|
|
168
|
+
}
|
|
169
|
+
return tokens.length;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Register the control-plane admin routes. Called by `createAuthRoutes` **before** the Better Auth
|
|
174
|
+
* catch-all; see the file header for why that ordering is load-bearing.
|
|
175
|
+
*/
|
|
176
|
+
export function registerAuthAdminRoutes(wiring: AuthWiring): (app: Hono<PithyHonoEnv>) => void {
|
|
177
|
+
return (app) => {
|
|
178
|
+
const base = wiring.config.basePath;
|
|
179
|
+
|
|
180
|
+
app.get(
|
|
181
|
+
`${base}/admin/users`,
|
|
182
|
+
requireControlPlane(AUTH_USERS_READ_SCOPE),
|
|
183
|
+
zValidator("query", ListUsersQuery, validationHook),
|
|
184
|
+
async (c) => {
|
|
185
|
+
const query = c.req.valid("query");
|
|
186
|
+
const caller = controlPlaneCaller(c);
|
|
187
|
+
const page = await listUsers(db(c, wiring), query);
|
|
188
|
+
await emitControlPlaneAction(c.var.emit, {
|
|
189
|
+
action: AuthAuditActions.adminUsersListed,
|
|
190
|
+
subject: caller.subject,
|
|
191
|
+
connectionId: caller.connectionId,
|
|
192
|
+
resourceType: "user",
|
|
193
|
+
...context(c),
|
|
194
|
+
// `searched`, not the term. A search term on this route is usually somebody's email address,
|
|
195
|
+
// and the audit trail is queryable and kept far longer than the pane that displayed it — so
|
|
196
|
+
// recording the term would copy the personal data the caller already saw into a second store
|
|
197
|
+
// with a different retention policy. That it was a search, and how much came back, is what
|
|
198
|
+
// makes an exfiltration pattern visible; the string itself adds nothing to that.
|
|
199
|
+
metadata: { searched: query.search !== undefined, returned: page.items.length },
|
|
200
|
+
});
|
|
201
|
+
return c.json({ users: page.items.map(userView), nextCursor: page.nextCursor } satisfies AdminUsersResponse);
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
app.get(
|
|
206
|
+
`${base}/admin/users/:userId`,
|
|
207
|
+
requireControlPlane(AUTH_USERS_READ_SCOPE),
|
|
208
|
+
zValidator("param", UserIdParam, validationHook),
|
|
209
|
+
async (c) => {
|
|
210
|
+
const { userId } = c.req.valid("param");
|
|
211
|
+
const caller = controlPlaneCaller(c);
|
|
212
|
+
const database = db(c, wiring);
|
|
213
|
+
|
|
214
|
+
const user = await getUser(database, userId);
|
|
215
|
+
if (!user)
|
|
216
|
+
throw new NotFoundError({ message: "No such user.", detail: `no pithy_auth_users row for ${userId}` });
|
|
217
|
+
|
|
218
|
+
// Bounded, because a device id is client-generated: a user can mint as many device rows as they
|
|
219
|
+
// like, so an unbounded sub-list would let any end user decide how much work this pane does.
|
|
220
|
+
//
|
|
221
|
+
// Each read is guarded on its own (#380). They are three independent tables and this was a
|
|
222
|
+
// `Promise.all`, so one of them failing 500'd the whole page — the user, their sessions and
|
|
223
|
+
// their devices all lost to whichever list would not read, on the pane a support agent opens
|
|
224
|
+
// when an account is already in trouble. Still concurrent: the guard is inside each arm.
|
|
225
|
+
const [sessions, devices, providers] = await Promise.all([
|
|
226
|
+
readBounded(() => listUserSessions(database, userId, MAX_PAGE_SIZE), sessionView),
|
|
227
|
+
readBounded(() => listUserDevices(database, userId, MAX_PAGE_SIZE), deviceView),
|
|
228
|
+
readWhole(() => userProviders(database, userId)),
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
await emitControlPlaneAction(c.var.emit, {
|
|
232
|
+
action: AuthAuditActions.adminUserRead,
|
|
233
|
+
subject: caller.subject,
|
|
234
|
+
connectionId: caller.connectionId,
|
|
235
|
+
resourceType: "user",
|
|
236
|
+
resourceId: userId,
|
|
237
|
+
...context(c),
|
|
238
|
+
// `null` where a list did not read, never `0`. The trail is what answers "how much did this
|
|
239
|
+
// caller see", and a zero there is a claim that the user had none.
|
|
240
|
+
metadata: {
|
|
241
|
+
sessions: sessions.state === "read" ? sessions.items.length : null,
|
|
242
|
+
devices: devices.state === "read" ? devices.items.length : null,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return c.json({
|
|
247
|
+
user: userView(user),
|
|
248
|
+
providers,
|
|
249
|
+
sessions,
|
|
250
|
+
devices,
|
|
251
|
+
} satisfies AdminUserResponse);
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
app.get(
|
|
256
|
+
`${base}/admin/devices`,
|
|
257
|
+
requireControlPlane(AUTH_DEVICES_READ_SCOPE),
|
|
258
|
+
zValidator("query", ListDevicesQuery, validationHook),
|
|
259
|
+
async (c) => {
|
|
260
|
+
const query = c.req.valid("query");
|
|
261
|
+
const caller = controlPlaneCaller(c);
|
|
262
|
+
const page = await listDeviceRegistry(db(c, wiring), query);
|
|
263
|
+
await emitControlPlaneAction(c.var.emit, {
|
|
264
|
+
action: AuthAuditActions.adminDevicesListed,
|
|
265
|
+
subject: caller.subject,
|
|
266
|
+
connectionId: caller.connectionId,
|
|
267
|
+
resourceType: "device",
|
|
268
|
+
resourceId: query.userId ?? null,
|
|
269
|
+
...context(c),
|
|
270
|
+
metadata: { returned: page.items.length, filteredByUser: query.userId !== undefined },
|
|
271
|
+
});
|
|
272
|
+
return c.json({
|
|
273
|
+
devices: page.items.map(deviceView),
|
|
274
|
+
nextCursor: page.nextCursor,
|
|
275
|
+
} satisfies AdminDevicesResponse);
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
app.post(
|
|
280
|
+
`${base}/admin/sessions/revoke`,
|
|
281
|
+
requireControlPlane(AUTH_SESSIONS_REVOKE_SCOPE),
|
|
282
|
+
zValidator("json", RevokeSessionBody, validationHook),
|
|
283
|
+
async (c) => {
|
|
284
|
+
const { sessionId } = c.req.valid("json");
|
|
285
|
+
const caller = controlPlaneCaller(c);
|
|
286
|
+
const session = await findSessionById(db(c, wiring), sessionId);
|
|
287
|
+
|
|
288
|
+
// Idempotent rather than a 404, so a retried job — the normal shape of automated incident
|
|
289
|
+
// response — lands on the state the caller meant instead of failing the second time. It also
|
|
290
|
+
// keeps this scope from answering "does this session exist" any more precisely than "did I
|
|
291
|
+
// revoke one", which matters because holding it confers no right to read the session at all.
|
|
292
|
+
const revoked = session ? await revokeTokens(c, wiring, [session.token]) : 0;
|
|
293
|
+
|
|
294
|
+
await emitControlPlaneAction(c.var.emit, {
|
|
295
|
+
action: AuthAuditActions.adminSessionRevoked,
|
|
296
|
+
subject: caller.subject,
|
|
297
|
+
connectionId: caller.connectionId,
|
|
298
|
+
resourceType: "session",
|
|
299
|
+
resourceId: sessionId,
|
|
300
|
+
...context(c),
|
|
301
|
+
// The owning user reaches the trail, where it belongs, and not the response — the caller holds
|
|
302
|
+
// a revoke scope, which is not a license to learn whose session it was.
|
|
303
|
+
metadata: { revoked, userId: session?.userId ?? null },
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return c.json({ revoked } satisfies AdminRevokeResponse);
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
app.post(
|
|
311
|
+
`${base}/admin/users/:userId/sessions/revoke`,
|
|
312
|
+
requireControlPlane(AUTH_USERS_LOGOUT_SCOPE),
|
|
313
|
+
zValidator("param", UserIdParam, validationHook),
|
|
314
|
+
async (c) => {
|
|
315
|
+
const { userId } = c.req.valid("param");
|
|
316
|
+
const caller = controlPlaneCaller(c);
|
|
317
|
+
const tokens = await userSessionTokens(db(c, wiring), userId);
|
|
318
|
+
const revoked = await revokeTokens(c, wiring, tokens);
|
|
319
|
+
|
|
320
|
+
await emitControlPlaneAction(c.var.emit, {
|
|
321
|
+
action: AuthAuditActions.adminUserSessionsRevoked,
|
|
322
|
+
subject: caller.subject,
|
|
323
|
+
connectionId: caller.connectionId,
|
|
324
|
+
resourceType: "user",
|
|
325
|
+
resourceId: userId,
|
|
326
|
+
...context(c),
|
|
327
|
+
metadata: { revoked },
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// No 404 for an unknown user, for the same two reasons as the single-session revoke: signing out
|
|
331
|
+
// somebody who is already signed out everywhere is a success, and this scope is not a read scope.
|
|
332
|
+
return c.json({ revoked } satisfies AdminRevokeResponse);
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
app.post(
|
|
337
|
+
`${base}/admin/users/:userId/devices/revoke`,
|
|
338
|
+
requireControlPlane(AUTH_DEVICES_REVOKE_SCOPE),
|
|
339
|
+
zValidator("param", UserIdParam, validationHook),
|
|
340
|
+
zValidator("json", RevokeDeviceBody, validationHook),
|
|
341
|
+
async (c) => {
|
|
342
|
+
const { userId } = c.req.valid("param");
|
|
343
|
+
const { deviceId } = c.req.valid("json");
|
|
344
|
+
const caller = controlPlaneCaller(c);
|
|
345
|
+
const database = db(c, wiring);
|
|
346
|
+
|
|
347
|
+
// Both halves are scoped to the named user, which is what the devices table's composite primary
|
|
348
|
+
// key exists for: a device id belonging to somebody else matches nothing here rather than
|
|
349
|
+
// revoking a stranger's phone.
|
|
350
|
+
const tokens = await deviceSessionTokens(database, userId, deviceId);
|
|
351
|
+
const revoked = await revokeTokens(c, wiring, tokens);
|
|
352
|
+
const removed = await deleteDevice(database, userId, deviceId);
|
|
353
|
+
|
|
354
|
+
await emitControlPlaneAction(c.var.emit, {
|
|
355
|
+
action: AuthAuditActions.adminDeviceRevoked,
|
|
356
|
+
subject: caller.subject,
|
|
357
|
+
connectionId: caller.connectionId,
|
|
358
|
+
resourceType: "device",
|
|
359
|
+
resourceId: deviceId,
|
|
360
|
+
...context(c),
|
|
361
|
+
metadata: { userId, revoked, removed },
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
return c.json({ revoked, removed } satisfies AdminDeviceRevokeResponse);
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
};
|
|
368
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type AmbientEnv, ambientEnv, compositionEnvironment } from "@pithy-sh/core/src/env/ambient";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a composition's base URL comes from — and therefore what its session cookie is called, and
|
|
8
|
+
* which origin its CSRF gate calls its own.
|
|
9
|
+
*
|
|
10
|
+
* ## The shape, and the two that lost
|
|
11
|
+
*
|
|
12
|
+
* `baseURL` stays **one string, and it means the deployed origin**. A `dev` composition does not read
|
|
13
|
+
* it: it resolves its base URL from the host the request actually arrived on, over `http`.
|
|
14
|
+
*
|
|
15
|
+
* A per-environment record (`{ prod, staging, dev }`, the way a Worker's `domains` block reads) was the
|
|
16
|
+
* obvious shape and it fails on the one value it exists to hold. The dev port is assigned per Worker per
|
|
17
|
+
* run from the feature's reserved block, so `dev: "http://localhost:8787"` is wrong the moment a second
|
|
18
|
+
* Worker starts or that port is taken — and a record whose `dev` key must be left empty to be correct is
|
|
19
|
+
* a field that only ever holds a mistake.
|
|
20
|
+
*
|
|
21
|
+
* Resolving from the request is what survives that, because the request is the only thing that knows the
|
|
22
|
+
* port. It cannot be stale, it needs no cooperation from `pithy dev`, and nothing has to be written down.
|
|
23
|
+
*
|
|
24
|
+
* ## We run no TLS locally, so the scheme is a constant
|
|
25
|
+
*
|
|
26
|
+
* That is settled policy, and it is what makes the rest hold **by construction** rather than by comment.
|
|
27
|
+
* `devBaseURL` fixes the scheme and takes only the host; {@link sessionCookieName} reads only the scheme.
|
|
28
|
+
* So the part nobody can know ahead of time — the port — cannot reach the cookie name, and the part that
|
|
29
|
+
* decides the cookie name is a constant. The dev seed and the running composition agree because they are
|
|
30
|
+
* reading the same two lines, not because they were written on the same day.
|
|
31
|
+
*
|
|
32
|
+
* ## One gate, on the environment alone
|
|
33
|
+
*
|
|
34
|
+
* {@link baseURLResolver} contains the only environment test in this seam: one `if`, on
|
|
35
|
+
* `compositionEnvironment` and nothing else, and-ed and or-ed with nothing. Outside `dev` it returns the
|
|
36
|
+
* configured value verbatim for every request, so a staging or production Worker resolves exactly what it
|
|
37
|
+
* resolved before this module existed — and the same-origin set it builds is byte-identical, because the
|
|
38
|
+
* origin the gate adds is the origin the configured base URL already contributed.
|
|
39
|
+
*
|
|
40
|
+
* `undefined` — nothing stamped `ENVIRONMENT` — is not `dev`, which is `compositionEnvironment`'s own
|
|
41
|
+
* rule and the right one here too: a deployment whose `wrangler.jsonc` lost the var must not start
|
|
42
|
+
* trusting whatever host a request claims to have arrived at.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** The one environment whose base URL is derived from the request rather than read from config. */
|
|
46
|
+
const DERIVED_ENVIRONMENT = "dev";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The scheme every `dev` composition serves on.
|
|
50
|
+
*
|
|
51
|
+
* Not a default and not a guess: Pithy runs no TLS locally, so there is no second possibility to choose
|
|
52
|
+
* between. It is exported because the dev-session seed needs it to name the cookie it writes, and that
|
|
53
|
+
* agreement is the whole invariant.
|
|
54
|
+
*/
|
|
55
|
+
export const DEV_PROTOCOL = "http:";
|
|
56
|
+
|
|
57
|
+
/** Better Auth's session cookie, before any prefix — `<cookiePrefix>.session_token`, prefix unchanged. */
|
|
58
|
+
const SESSION_COOKIE = "better-auth.session_token";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The base URL a `dev` composition resolves for a request that arrived at `host`.
|
|
62
|
+
*
|
|
63
|
+
* `host`, not `hostname`: the port is the whole point, and it is the part of a dev address that nobody
|
|
64
|
+
* can know before the run allocates it.
|
|
65
|
+
*/
|
|
66
|
+
export function devBaseURL(host: string): string {
|
|
67
|
+
return `${DEV_PROTOCOL}//${host}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A base URL's scheme, or `""` for a string that is not a URL at all.
|
|
72
|
+
*
|
|
73
|
+
* Tolerant rather than throwing, for the same reason the CSRF gate's `originOf` is: `baseURL` is a
|
|
74
|
+
* `z.string()` an adopter writes by hand, and a malformed one must fail as "no `__Secure-` prefix" —
|
|
75
|
+
* matching what Better Auth itself does with it — not as a 500 on every request.
|
|
76
|
+
*/
|
|
77
|
+
export function baseURLProtocol(baseURL: string): string {
|
|
78
|
+
try {
|
|
79
|
+
return new URL(baseURL).protocol;
|
|
80
|
+
} catch {
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Better Auth's session cookie name for a base URL served over `protocol`.
|
|
87
|
+
*
|
|
88
|
+
* Our mirror of a rule that lives in Better Auth: it adds the `__Secure-` prefix when the base URL is
|
|
89
|
+
* HTTPS. Mirrored because the seed has to name the cookie before any instance exists, and pinned to the
|
|
90
|
+
* running version by a test that reads the name off a live instance — so an upgrade that changed the
|
|
91
|
+
* rule fails there rather than in a browser.
|
|
92
|
+
*/
|
|
93
|
+
export function sessionCookieName(protocol: string): string {
|
|
94
|
+
return protocol === "https:" ? `__Secure-${SESSION_COOKIE}` : SESSION_COOKIE;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Resolve the base URL this composition serves under, for one request. */
|
|
98
|
+
export type ResolveBaseURL = (request: Request) => string;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build this composition's base-URL resolver. The environment is read once, here, and nowhere else in
|
|
102
|
+
* this seam.
|
|
103
|
+
*/
|
|
104
|
+
export function baseURLResolver(configured: string, env: AmbientEnv = ambientEnv()): ResolveBaseURL {
|
|
105
|
+
// The gate. Its own condition, on the environment alone — never folded into the per-request check,
|
|
106
|
+
// where a dev relaxation would be one edit away from applying in production.
|
|
107
|
+
if (compositionEnvironment(env) !== DERIVED_ENVIRONMENT) return () => configured;
|
|
108
|
+
return (request) => devBaseURL(new URL(request.url).host);
|
|
109
|
+
}
|
package/src/http/csrf.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PithyMiddleware } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { type AmbientEnv, ambientEnv } from "@pithy-sh/core/src/env/ambient";
|
|
6
|
+
import { ForbiddenError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import type { SameOriginGate } from "@pithy-sh/core/src/http/sameOrigin";
|
|
8
|
+
import type { AuthWiring } from "../capability";
|
|
9
|
+
import { baseURLResolver, type ResolveBaseURL } from "./baseUrl";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* CSRF origin guard for every state-changing route in the Worker — Pithy's own (token rotation, device
|
|
13
|
+
* revoke) and the adopter's alike.
|
|
14
|
+
*
|
|
15
|
+
* Better Auth applies this check to its own endpoints; routes in front of the catch-all must enforce
|
|
16
|
+
* it themselves to honor principle 2 ("cookie/session mode ⇒ CSRF on"). Bearer requests carry no
|
|
17
|
+
* ambient credential, so they are CSRF-exempt and pass through. A cookie-authenticated request must
|
|
18
|
+
* present an `Origin` (or `Referer`) matching an allowed origin.
|
|
19
|
+
*
|
|
20
|
+
* ## Nothing here takes an origin list
|
|
21
|
+
*
|
|
22
|
+
* The gate used to be built by whoever needed it, from origins they passed in. Auth's own routes had
|
|
23
|
+
* the resolved ones; an adopter's routes did not — the `routes` hook receives only the Hono app — so
|
|
24
|
+
* the adopter wrote the check again from the request and hoped the two agreed. One Worker, two
|
|
25
|
+
* same-origin implementations, free to drift, and the weaker one is the real policy.
|
|
26
|
+
*
|
|
27
|
+
* Now the capability publishes the gate **already bound** to the origins it resolved, and core's
|
|
28
|
+
* `requireSameOrigin()` takes no argument. There is no list to pass, so there is no wrong list to
|
|
29
|
+
* pass — and no second implementation to keep in step.
|
|
30
|
+
*/
|
|
31
|
+
function originOf(value: string | null): string | null {
|
|
32
|
+
if (!value) return null;
|
|
33
|
+
try {
|
|
34
|
+
return new URL(value).origin;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The origins a cookie-authenticated mutating request may come from: the base URL plus trustedOrigins. */
|
|
41
|
+
function allowedOrigins(baseURL: string, trustedOrigins: readonly string[]): string[] {
|
|
42
|
+
const origins = new Set<string>(trustedOrigins);
|
|
43
|
+
const base = originOf(baseURL);
|
|
44
|
+
if (base) origins.add(base);
|
|
45
|
+
return [...origins];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The decision itself, closed over the one resolved origin set. Never exported: see the module doc. */
|
|
49
|
+
function sameOriginGate(allowedOrigins: readonly string[], resolveBaseURL: ResolveBaseURL): SameOriginGate {
|
|
50
|
+
const allowed = new Set(allowedOrigins);
|
|
51
|
+
return async (c, next) => {
|
|
52
|
+
// A bearer request has no ambient credential to forge — CSRF-exempt.
|
|
53
|
+
if (c.req.raw.headers.has("authorization")) {
|
|
54
|
+
await next();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const origin = c.req.raw.headers.get("origin") ?? originOf(c.req.raw.headers.get("referer"));
|
|
58
|
+
// The origin this composition is serving on, for this request. Outside `dev` it is the configured
|
|
59
|
+
// base URL's, which `allowedOrigins` already put in the set — so this adds nothing and production
|
|
60
|
+
// is byte-for-byte what it was. In `dev` it is the live local origin, which is the only correct
|
|
61
|
+
// answer when the port is assigned per Worker per run.
|
|
62
|
+
const own = originOf(resolveBaseURL(c.req.raw));
|
|
63
|
+
if (!origin || (origin !== own && !allowed.has(origin))) {
|
|
64
|
+
throw new ForbiddenError({
|
|
65
|
+
message: "Cross-origin request rejected.",
|
|
66
|
+
action: "Send the request from an allowed origin, or use a bearer token.",
|
|
67
|
+
detail: `origin ${origin ?? "(none)"} is not in trustedOrigins`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
await next();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Publish the bound gate on every request, as capability middleware.
|
|
76
|
+
*
|
|
77
|
+
* The configured origin set is built once at composition: it is resolved config, and re-deriving it on
|
|
78
|
+
* the hot path is work that can only ever produce the same answer — or a different one, which is the
|
|
79
|
+
* bug. The composition's *own* origin is the one part that is not config, and only in `dev`: it is the
|
|
80
|
+
* address this run was assigned, which no config file can hold. So the resolver is built here, at
|
|
81
|
+
* composition, with the environment gate inside it ({@link baseURLResolver}), and the gate it hands
|
|
82
|
+
* back is a constant everywhere but `dev`.
|
|
83
|
+
*
|
|
84
|
+
* Registered on `*` rather than under `basePath`, because the routes that most need it are the
|
|
85
|
+
* adopter's, and those are anywhere.
|
|
86
|
+
*/
|
|
87
|
+
export function publishSameOrigin(wiring: AuthWiring, env: AmbientEnv = ambientEnv()): PithyMiddleware {
|
|
88
|
+
const gate = sameOriginGate(
|
|
89
|
+
allowedOrigins(wiring.config.baseURL, wiring.config.trustedOrigins),
|
|
90
|
+
baseURLResolver(wiring.config.baseURL, env),
|
|
91
|
+
);
|
|
92
|
+
return (app) => {
|
|
93
|
+
app.use("*", async (c, next) => {
|
|
94
|
+
c.set("sameOrigin", gate);
|
|
95
|
+
await next();
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
}
|