@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/docs/apple-signin.md +139 -0
  4. package/docs/facebook-oauth.md +92 -0
  5. package/docs/github-oauth.md +99 -0
  6. package/docs/google-oauth.md +118 -0
  7. package/package.json +58 -0
  8. package/pithy.manifest.json +108 -0
  9. package/src/admin/users.ts +357 -0
  10. package/src/audit/actions.ts +71 -0
  11. package/src/audit/emit.ts +223 -0
  12. package/src/capability.ts +300 -0
  13. package/src/client/api.ts +501 -0
  14. package/src/client/projection.ts +55 -0
  15. package/src/cloudflare-test.d.ts +16 -0
  16. package/src/data/betterAuth.ts +210 -0
  17. package/src/data/device.ts +57 -0
  18. package/src/data/kitFields.ts +69 -0
  19. package/src/data/rotatedToken.ts +40 -0
  20. package/src/data/tables.ts +38 -0
  21. package/src/device/registry.ts +139 -0
  22. package/src/email/send.ts +67 -0
  23. package/src/http/adminRoutes.ts +368 -0
  24. package/src/http/baseUrl.ts +109 -0
  25. package/src/http/csrf.ts +98 -0
  26. package/src/http/devLoginRoute.ts +159 -0
  27. package/src/http/errors.ts +70 -0
  28. package/src/http/guards.ts +158 -0
  29. package/src/http/middleware.ts +67 -0
  30. package/src/http/rateLimit.ts +36 -0
  31. package/src/http/resolve.ts +152 -0
  32. package/src/http/responses.ts +199 -0
  33. package/src/http/routes.ts +325 -0
  34. package/src/http/schemas.ts +118 -0
  35. package/src/http/views.ts +93 -0
  36. package/src/i18n/errorCopy.es.ts +35 -0
  37. package/src/i18n/errorCopy.ts +99 -0
  38. package/src/index.ts +24 -0
  39. package/src/instance/auth.ts +309 -0
  40. package/src/instance/plugins.ts +172 -0
  41. package/src/instance/providers.ts +185 -0
  42. package/src/instance/secrets.ts +197 -0
  43. package/src/migrations/0001_init.ts +229 -0
  44. package/src/migrations/pluginTables.ts +334 -0
  45. package/src/seeds/devSession.ts +286 -0
  46. package/src/seeds/example.ts +48 -0
  47. package/src/test-utils/liveApp.ts +338 -0
  48. package/src/token/rotation.ts +104 -0
  49. 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
+ }
@@ -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
+ }