@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,159 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
5
+ import { JsonDate } from "@pithy-sh/core/src/data/codecs";
6
+ import { type AmbientEnv, ambientEnv, compositionEnvironment } from "@pithy-sh/core/src/env/ambient";
7
+ import { isContinuousIntegration } from "@pithy-sh/core/src/env/ci";
8
+ import { fromZodError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
9
+ import { DEV_LOGIN_ROUTE } from "@pithy-sh/core/src/seed/devLogin";
10
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
11
+ import type { Context, Hono } from "hono";
12
+ import { z } from "zod";
13
+ import type { AuthWiring } from "../capability";
14
+ import { authDatabase } from "../data/tables";
15
+ import { resolveSessionSecret } from "../instance/secrets";
16
+ import {
17
+ DEV_SESSION_COOKIE_NAME,
18
+ DEV_SESSION_TOKEN_PREFIX,
19
+ secretFingerprint,
20
+ signCookieValue,
21
+ } from "../seeds/devSession";
22
+ import { resolveDb } from "./resolve";
23
+
24
+ /**
25
+ * `GET /__pithy/dev-login` — the seeded session, as a redirect that signs the browser in.
26
+ *
27
+ * The seed already mints a real session row and a real signed cookie. What was missing was a way to
28
+ * *hand it to a browser*: the artifact is a file, and the only route to a browser from a file was the
29
+ * terminal — `document.cookie = "…"`, printed on the ready banner, pasted into a console. That line put
30
+ * a live session token in the one place a credential must never be, which is a place people read, copy,
31
+ * screenshot, and tee into a log. A URL carries the same credential to the same browser without it ever
32
+ * being rendered as text.
33
+ *
34
+ * ## The two gates, and why they are two statements
35
+ *
36
+ * This route mints an authenticated session with **no credential presented**. That is the entire risk of
37
+ * the feature, so it is refused twice, independently:
38
+ *
39
+ * 1. The composition's environment is not `dev`.
40
+ * 2. `CI` is set to any non-blank value — even in a `dev` composition.
41
+ *
42
+ * Neither implies the other. CI runs `dev` compositions constantly (integration suites, packaging
43
+ * checks, `pithy dev` itself), and a developer's laptop is not CI, so "not `dev`" does not cover CI and
44
+ * "not CI" does not cover production. Written as one `||` the pair reads like a single condition, and a
45
+ * single condition is one edit from an `&&` — with a session-minting endpoint answering as the failure
46
+ * mode, silently. Two `if`s, two comments, two tests.
47
+ *
48
+ * ## And the gates are at registration
49
+ *
50
+ * Not inside the handler. A route that exists and refuses is one refactor away from a route that exists
51
+ * and does not, and it is visible in the route table of a production Worker, where its presence alone is
52
+ * a finding. The assertion the tests make is therefore about what a composition *mounts*.
53
+ *
54
+ * **What the CI gate can and cannot see.** In a Worker, `process.env` is the script's bindings and
55
+ * nothing else — the shell's `CI` does not cross into workerd. `pithy dev` forwards it as a var for
56
+ * exactly this reason, so the read is truthful for every Worker Pithy starts; a Worker started some
57
+ * other way in CI is covered only by the environment gate. That is why there are two, and why the
58
+ * second is the one that needs no cooperation.
59
+ */
60
+ export function registerDevLoginRoute(
61
+ wiring: AuthWiring,
62
+ env: AmbientEnv = ambientEnv(),
63
+ ): (app: Hono<PithyHonoEnv>) => void {
64
+ return (app) => {
65
+ // Gate one: the composition's environment. `undefined` — nothing stamped `ENVIRONMENT` — is not `dev`.
66
+ if (compositionEnvironment(env) !== "dev") return;
67
+ // Gate two: continuous integration, independently. A `dev` composition in CI gets no route either.
68
+ if (isContinuousIntegration(env)) return;
69
+ app.get(DEV_LOGIN_ROUTE, (c) => serveDevLogin(c, wiring));
70
+ };
71
+ }
72
+
73
+ /**
74
+ * The seeded session row, narrowed to what handing it to a browser needs — and validated, because a D1
75
+ * row is a boundary like any other. `z.input` is the stored row (ISO-8601 text); `z.output` the app shape.
76
+ */
77
+ const DevSessionRow = z
78
+ .object({
79
+ token: z
80
+ .string()
81
+ .min(1)
82
+ .describe("The seeded session's opaque token — signed with this environment's secret to make the cookie."),
83
+ expiresAt: JsonDate.describe("When the seeded session expires. ISO-8601 text in SQLite; a `Date` here."),
84
+ })
85
+ .describe("A seeded dev session, narrowed to the two columns the dev-login redirect needs.");
86
+ type DevSessionRow = z.output<typeof DevSessionRow>;
87
+
88
+ /** How long the browser is told to keep the cookie: whatever is left of the seeded session, never longer. */
89
+ function maxAgeSeconds(expiresAt: Date, now: Date): number {
90
+ return Math.floor((expiresAt.getTime() - now.getTime()) / 1000);
91
+ }
92
+
93
+ /**
94
+ * The refusal when there is nothing to sign in as. A 404, because the honest answer is that this
95
+ * composition has no seeded session — not that the caller got something wrong.
96
+ *
97
+ * It names `pithy seed` because that is the command that mints one, and it carries no detail about what
98
+ * was searched for: the search key is derived from the signing secret.
99
+ *
100
+ * **In `message`, not in `action` (#344).** `action` is the operator's field and the HTTP codec strips it,
101
+ * because on every other route the caller is somebody who must not be handed a `pithy` command. This route
102
+ * is the exception the gates above already make: it registers only in a `dev` composition outside CI, so
103
+ * the browser at the other end is the developer's own. That is a decision one route makes in the open,
104
+ * which is the opposite of a field nobody classified carrying it everywhere.
105
+ */
106
+ function noSeededSession(): NotFoundError {
107
+ return new NotFoundError({
108
+ message: "No dev login has been seeded for this environment. Run pithy seed, then open this URL again.",
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Serve the seeded session as a `Set-Cookie` and a redirect to `/`.
114
+ *
115
+ * The row is found by the prefix every seeded session carries and **verified against the current
116
+ * signing secret's fingerprint**, which the seed puts in the token for this exact purpose. A session
117
+ * minted before a rotation is a cookie Better Auth will reject, so handing it over would sign nobody in
118
+ * and send the developer hunting through auth for a bug that is a stale seed. Unfound is unfound.
119
+ *
120
+ * Nothing about the cookie is logged, and nothing is written to a response body: the value exists in
121
+ * this handler and in the browser, and in no third place.
122
+ */
123
+ async function serveDevLogin(c: Context<PithyHonoEnv>, wiring: AuthWiring): Promise<Response> {
124
+ const secret = await resolveSessionSecret(c.env as unknown as SecretsStoreEnv);
125
+ const fingerprint = await secretFingerprint(secret);
126
+
127
+ const db = authDatabase(resolveDb(c.env, wiring.config.database));
128
+ const found = await db
129
+ .selectFrom("pithyAuthSessions")
130
+ .select(["token", "expiresAt"])
131
+ .where("token", "like", `${DEV_SESSION_TOKEN_PREFIX}%-${fingerprint}`)
132
+ .orderBy("createdAt", "desc")
133
+ .limit(1)
134
+ .executeTakeFirst();
135
+ if (!found) throw noSeededSession();
136
+
137
+ const parsed = DevSessionRow.safeParse(found);
138
+ if (!parsed.success) {
139
+ throw fromZodError(parsed.error, {
140
+ // Same reason as `noSeededSession`: this route's caller is the developer who can run the command.
141
+ message: "The seeded dev session is not readable. Run pithy seed to mint a fresh one.",
142
+ });
143
+ }
144
+ const row: DevSessionRow = parsed.data;
145
+ const maxAge = maxAgeSeconds(row.expiresAt, new Date());
146
+ // An expired session is worse than none: the cookie looks like a way in and fails silently in the
147
+ // browser. Same answer, same action — reseeding is what fixes both.
148
+ if (maxAge <= 0) throw noSeededSession();
149
+
150
+ const value = await signCookieValue(row.token, secret);
151
+ // The attributes Better Auth's own session cookie carries in a `dev` composition: `HttpOnly` (a
152
+ // session token has no business in `document.cookie`, which is also the habit this route retires),
153
+ // `SameSite=Lax`, root path. No `Secure` — a `dev` base URL is `http://localhost`, and a `Secure`
154
+ // cookie there is one the browser accepts and never sends back.
155
+ const cookie = `${DEV_SESSION_COOKIE_NAME}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
156
+ // 302 to the app root rather than 200 with a page: the developer asked to be signed in, not to read a
157
+ // confirmation, and a redirect leaves the address bar on the app instead of on this route.
158
+ return c.body(null, 302, { "Set-Cookie": cookie, Location: "/" });
159
+ }
@@ -0,0 +1,70 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import {
5
+ ConflictError,
6
+ ForbiddenError,
7
+ InternalError,
8
+ NotFoundError,
9
+ PithyError,
10
+ RateLimitError,
11
+ UnauthorizedError,
12
+ ValidationError,
13
+ } from "@pithy-sh/core/src/error/pithyError";
14
+ import { isAPIError } from "better-auth/api";
15
+
16
+ /**
17
+ * Translate a thrown Better-Auth `APIError` into the matching `PithyError` subclass, by HTTP status.
18
+ *
19
+ * **What reaches here is a much smaller set than `onAPIError: { throw: true }` suggests.** That option
20
+ * reads as though every endpoint's `APIError` bubbles to the Hono boundary, and none of them do:
21
+ * better-auth's `onError` re-raises (`better-auth/dist/api/index.mjs:193`) straight into better-call's
22
+ * own catch (`better-call@1.4.0`, `dist/router.mjs:83-89`), which renders an `APIError` as a Response
23
+ * and returns it. An endpoint refusal is therefore an ordinary non-2xx answer, and `handleBetterAuth`
24
+ * hands it back untouched (#449).
25
+ *
26
+ * What still arrives is what better-call declined to handle: a non-`APIError` throw from an endpoint
27
+ * (`throw error` at `router.mjs:88` — a database failure, a genuine bug), and a throw from a plugin's
28
+ * `onRequest` hook, which runs outside the router's try. The `isAPIError` branch is kept for those,
29
+ * which can carry one, and because a caller other than the delegating route may hand this anything.
30
+ *
31
+ * The shape, when it is one: `{ statusCode, status, message, body: { message, code } }`. We re-home it
32
+ * in the one `PithyError` family so the HTTP codec owns the response shape and strips `detail`. For 4xx the
33
+ * Better-Auth message is user-actionable and safe to surface (it never reveals account existence —
34
+ * sign-up is silently no-op'd for unknown users); 5xx gets a generic public message. The Better-Auth
35
+ * status + error code stay in `detail` (logs/audit only, never the client).
36
+ */
37
+ export function apiErrorToPithy(error: unknown): PithyError {
38
+ if (error instanceof PithyError) return error;
39
+ if (!isAPIError(error)) {
40
+ return new InternalError({
41
+ message: "Authentication failed.",
42
+ detail: error instanceof Error ? error.message : String(error),
43
+ });
44
+ }
45
+ const e = error as {
46
+ statusCode?: number;
47
+ status?: unknown;
48
+ message?: string;
49
+ body?: { message?: string; code?: string };
50
+ };
51
+ const status = typeof e.statusCode === "number" ? e.statusCode : 500;
52
+ const publicMessage = e.body?.message ?? e.message ?? "Authentication failed.";
53
+ const detail = `better-auth ${String(e.status)}${e.body?.code ? ` ${e.body.code}` : ""}: ${e.message ?? ""}`.trim();
54
+ switch (status) {
55
+ case 400:
56
+ return new ValidationError({ message: publicMessage, detail });
57
+ case 401:
58
+ return new UnauthorizedError({ message: publicMessage, detail });
59
+ case 403:
60
+ return new ForbiddenError({ message: publicMessage, detail });
61
+ case 404:
62
+ return new NotFoundError({ message: publicMessage, detail });
63
+ case 409:
64
+ return new ConflictError({ message: publicMessage, detail });
65
+ case 429:
66
+ return new RateLimitError({ message: publicMessage, detail });
67
+ default:
68
+ return new InternalError({ message: "Authentication failed.", detail });
69
+ }
70
+ }
@@ -0,0 +1,158 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
5
+ import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
6
+
7
+ /**
8
+ * Auth's control-plane scopes, and the admin surface a manifest advertises.
9
+ *
10
+ * ## The gate is core's; auth contributes only the scope names
11
+ *
12
+ * `requireControlPlane` lives in `@pithy-sh/core/src/controlPlane/http/guard` and every admin route
13
+ * wears it directly. Auth verifies nothing itself: a management call arrives as an EdDSA-signed compact
14
+ * JWS on the `pithy-control-plane` header, and the seam checks the signature against a public key the
15
+ * **adopter** registered, the connection it addresses, that connection's environment, the token's
16
+ * lifetime, a digest of the body, and the token's single use.
17
+ *
18
+ * Importing that gate is not the opposite of `requireAuth` being copied into every other capability; it
19
+ * is the same rule. The rule is never to import authorization from a package that might be absent.
20
+ * `@pithy-sh/core` is a hard dependency of every capability there is, and with the seam *uncomposed*
21
+ * the imported gate raises `controlplane/not_connected` rather than passing. Both halves fail closed.
22
+ *
23
+ * ## `requireAuth()` must never appear on one of these routes
24
+ *
25
+ * This is the sharpest edge in this package, because auth is the capability that *implements*
26
+ * `requireAuth`. A management client is not a user of the adopter's app: it holds no session, owns no
27
+ * user row, and the seam deliberately leaves `c.var.auth` null so a control-plane credential cannot
28
+ * satisfy an ordinary `requireAuth()` anywhere in the tree. An auth gate on an admin route would deny
29
+ * every legitimate management call, permanently, and **no credential could fix it** — there is no user
30
+ * to sign in as. `controlPlaneIsolation.workers.test.ts` pins both directions of that separation.
31
+ *
32
+ * ## Five scopes, because these are five different blast radii
33
+ *
34
+ * The temptation is one `auth:admin` flag, and on this capability it is the most dangerous version of
35
+ * that mistake. **Reading a user is a privacy operation; revoking their sessions is an availability
36
+ * one.** A support tool that looks people up should never be able to sign the whole customer base out,
37
+ * and an incident-response tool that kills a stolen session has no business reading every address in
38
+ * the user table. `scopeCovers` matches exactly, with no prefix or wildcard rule, so holding one of
39
+ * these confers nothing whatever about the others — `auth:users` grants none of them.
40
+ *
41
+ * The split within *reads* is between one user and the fleet: `auth:users:read` answers "who is this
42
+ * person", while `auth:devices:read` walks every device of every user, which is a different question
43
+ * with a much larger answer. The split within *writes* is by blast radius: one session, one device, or
44
+ * every session a person has.
45
+ *
46
+ * The names are constants rather than config. A configurable scope name is a way to misconfigure a
47
+ * default-denied gate into a differently-named one, and tooling that read the docs would then hold a
48
+ * scope nothing checks. They are also the join key with what `pithy dashboard connect` offers an
49
+ * adopter to grant, so they must be the same strings in both places.
50
+ *
51
+ * ## There is no impersonation scope, and its absence is deliberate
52
+ *
53
+ * "Sign in as this user" is the most dangerous administrative capability there is: it produces a
54
+ * credential indistinguishable from the user's own, so every action taken with it reads in the trail as
55
+ * theirs. It is excluded from this surface on purpose and is not reachable by composing what is here —
56
+ * nothing below mints a session, and the read routes never project a session token. If it is ever
57
+ * built it gets its own design and its own security review, not a scope added to this list.
58
+ */
59
+
60
+ /**
61
+ * Look a user up and read their account: the listing, the search, and one user with their sessions and
62
+ * devices. The privacy-bearing read — it returns email addresses, IPs, and user agents — and by far the
63
+ * most commonly granted, because nearly every dashboard pane resolves to a user.
64
+ */
65
+ export const AUTH_USERS_READ_SCOPE: ControlPlaneScope = "auth:users:read";
66
+
67
+ /**
68
+ * Walk the device registry across every user. Separate from reading one user because the question is
69
+ * different in kind: this is fleet-wide, and answers "what is signing in to this product" rather than
70
+ * "what does this person use".
71
+ */
72
+ export const AUTH_DEVICES_READ_SCOPE: ControlPlaneScope = "auth:devices:read";
73
+
74
+ /**
75
+ * Revoke one named session. The targeted write — what an incident-response tool needs to kill a stolen
76
+ * token, and nothing more. Granting it confers no ability to read who the session belongs to.
77
+ */
78
+ export const AUTH_SESSIONS_REVOKE_SCOPE: ControlPlaneScope = "auth:sessions:revoke";
79
+
80
+ /**
81
+ * Sign one user out everywhere: every session on every device, at once. The most disruptive thing on
82
+ * this surface — the person is signed out of the product mid-use with no warning — which is exactly why
83
+ * it is granted separately from revoking a single session.
84
+ */
85
+ export const AUTH_USERS_LOGOUT_SCOPE: ControlPlaneScope = "auth:users:logout";
86
+
87
+ /**
88
+ * Sign one of a user's devices out and drop its registration, so it must register again at next
89
+ * sign-in. The admin counterpart of the user's own `POST /devices/revoke`, for a phone somebody
90
+ * reported lost — and destructive in a way the session revokes are not, because the device row and its
91
+ * push token go with it.
92
+ */
93
+ export const AUTH_DEVICES_REVOKE_SCOPE: ControlPlaneScope = "auth:devices:revoke";
94
+
95
+ /**
96
+ * Every control-plane scope auth defines — what `pithy dashboard connect` offers for this capability,
97
+ * and the list a manifest or a doc quotes rather than re-typing.
98
+ */
99
+ export const AUTH_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
100
+ AUTH_USERS_READ_SCOPE,
101
+ AUTH_DEVICES_READ_SCOPE,
102
+ AUTH_SESSIONS_REVOKE_SCOPE,
103
+ AUTH_USERS_LOGOUT_SCOPE,
104
+ AUTH_DEVICES_REVOKE_SCOPE,
105
+ ];
106
+
107
+ /**
108
+ * Auth's management surface, as `GET /control-plane/manifest` reports it.
109
+ *
110
+ * Declared beside the scopes rather than in `adminRoutes.ts`, so the scope a route demands and the
111
+ * scope a manifest advertises are the same constant read from one place. `basePath` is a parameter and
112
+ * never a default: an adopter who mounted auth at `/identity` must get a manifest naming
113
+ * `/identity/admin/users`, or a management client composing its calls from the manifest would 404
114
+ * against exactly the adopters who customized anything.
115
+ *
116
+ * The summaries say what the operation is *for*. A client renders these next to a button somebody is
117
+ * about to press on a real person's account.
118
+ */
119
+ export function authAdminRoutes(basePath: string): AdminRoute[] {
120
+ return [
121
+ {
122
+ method: "GET",
123
+ path: `${basePath}/admin/users`,
124
+ scope: AUTH_USERS_READ_SCOPE,
125
+ summary: "Find a user. Lists everyone newest first, or searches email and display name.",
126
+ },
127
+ {
128
+ method: "GET",
129
+ path: `${basePath}/admin/users/:userId`,
130
+ scope: AUTH_USERS_READ_SCOPE,
131
+ summary: "One user, with where they are signed in and what they sign in with.",
132
+ },
133
+ {
134
+ method: "GET",
135
+ path: `${basePath}/admin/devices`,
136
+ scope: AUTH_DEVICES_READ_SCOPE,
137
+ summary: "The device registry across users, most-recently-seen first.",
138
+ },
139
+ {
140
+ method: "POST",
141
+ path: `${basePath}/admin/sessions/revoke`,
142
+ scope: AUTH_SESSIONS_REVOKE_SCOPE,
143
+ summary: "Kill one session. The rest of that person's sign-ins keep working.",
144
+ },
145
+ {
146
+ method: "POST",
147
+ path: `${basePath}/admin/users/:userId/sessions/revoke`,
148
+ scope: AUTH_USERS_LOGOUT_SCOPE,
149
+ summary: "Sign a user out everywhere, on every device, immediately.",
150
+ },
151
+ {
152
+ method: "POST",
153
+ path: `${basePath}/admin/users/:userId/devices/revoke`,
154
+ scope: AUTH_DEVICES_REVOKE_SCOPE,
155
+ summary: "Sign one device out and forget it — for a phone reported lost.",
156
+ },
157
+ ];
158
+ }
@@ -0,0 +1,67 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyHonoEnv, PithyMiddleware } from "@pithy-sh/core/src/capability/capability";
5
+ import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { AuthContext } from "@pithy-sh/core/src/http/authContext";
7
+ import { isLocale } from "@pithy-sh/core/src/i18n/locale";
8
+ import type { MiddlewareHandler } from "hono";
9
+ import type { AuthWiring } from "../capability";
10
+ import { getAuthInstance } from "./resolve";
11
+
12
+ /**
13
+ * The session-resolution middleware: it fills core's `AuthContext` seam (`c.var.auth`) for every
14
+ * request that carries a credential — a bearer token or a session cookie. Better Auth resolves both
15
+ * transparently (the bearer plugin rewrites the header into the session cookie), so one `getSession`
16
+ * call covers mobile and web. A credential-less request stays anonymous (no instance build, no D1 hit).
17
+ * Resolution never throws — an invalid credential simply leaves `auth` null for `requireAuth` to reject.
18
+ */
19
+ export function createSessionMiddleware(wiring: AuthWiring): PithyMiddleware {
20
+ return (app) => {
21
+ app.use("*", async (c, next) => {
22
+ const headers = c.req.raw.headers;
23
+ if (headers.has("authorization") || headers.has("cookie")) {
24
+ try {
25
+ const instance = await getAuthInstance(c, wiring);
26
+ const session = await instance.api.getSession({ headers });
27
+ if (session) {
28
+ // `locale` comes off the user row the session lookup already loaded, so publishing it
29
+ // costs nothing — and it is the `user` link of `@pithy-sh/i18n`'s server chain, the one
30
+ // that makes a reader's stored choice outrank their device's `Accept-Language`. Read
31
+ // through `AuthContext.parse`, so a column holding something that is not a tag lands as
32
+ // `undefined` rather than reaching `Intl`.
33
+ const stored = (session.user as { locale?: unknown }).locale;
34
+ c.set(
35
+ "auth",
36
+ AuthContext.parse({
37
+ userId: session.user.id,
38
+ sessionId: session.session.id,
39
+ scopes: [],
40
+ locale: typeof stored === "string" && isLocale(stored) ? stored : null,
41
+ }),
42
+ );
43
+ }
44
+ } catch {
45
+ // Leave `auth` null; the credential was missing/invalid.
46
+ }
47
+ }
48
+ await next();
49
+ });
50
+ };
51
+ }
52
+
53
+ /**
54
+ * The gate other capabilities stack on a protected route. Rejects a request whose `AuthContext` was
55
+ * not filled (no valid credential) with a `PithyError` `auth/invalid_token` (401).
56
+ */
57
+ export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
58
+ return async (c, next) => {
59
+ if (!c.var.auth) {
60
+ throw new UnauthorizedError({
61
+ message: "Authentication required.",
62
+ action: "Sign in and retry with a valid session or bearer token.",
63
+ });
64
+ }
65
+ await next();
66
+ };
67
+ }
@@ -0,0 +1,36 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
5
+ import { RateLimitError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { MiddlewareHandler } from "hono";
7
+
8
+ /**
9
+ * Tier 1 of two: the coarse edge rate limiter.
10
+ *
11
+ * Cloudflare's native Workers Rate Limiting binding caps requests per client IP at the edge with no
12
+ * storage round-trip — blunting floods and credential-stuffing before they reach Better Auth's
13
+ * per-action D1 limiter (tier 2: the 5/min magic-link and 3/min OTP caps, keyed per identity). Two
14
+ * limiters, two jobs: this one is a cheap per-IP flood guard across every auth route; tier 2 is the
15
+ * fine-grained per-action cap.
16
+ *
17
+ * Keys on `cf-connecting-ip`. The limit and window are set on the binding in `wrangler.jsonc`, not
18
+ * here. Skips only when the binding is absent (local dev / tests) — in a real deployment the binding is
19
+ * a required binding, enforced at compose.
20
+ */
21
+ export function createRateLimitMiddleware(bindingName: string): MiddlewareHandler<PithyHonoEnv> {
22
+ return async (c, next) => {
23
+ const limiter = (c.env as Record<string, unknown>)[bindingName] as RateLimit | undefined;
24
+ if (limiter && typeof limiter.limit === "function") {
25
+ const key = c.req.raw.headers.get("cf-connecting-ip") ?? "anonymous";
26
+ const { success } = await limiter.limit({ key });
27
+ if (!success) {
28
+ throw new RateLimitError({
29
+ message: "Too many requests.",
30
+ action: "Slow down and retry in a moment.",
31
+ });
32
+ }
33
+ }
34
+ await next();
35
+ };
36
+ }
@@ -0,0 +1,152 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
6
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
7
+ import type { EmailEnqueueEnv } from "@pithy-sh/email/src/capability";
8
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
9
+ import type { Context } from "hono";
10
+ import type { AuthWiring } from "../capability";
11
+ import { authDatabase } from "../data/tables";
12
+ import { makeSendAuthEmail, type ResolveRecipientLocale } from "../email/send";
13
+ import { type AuthInstance, makeAuth } from "../instance/auth";
14
+ import { resolveProvider } from "../instance/providers";
15
+ import {
16
+ resolveAppleCredentials,
17
+ resolveFacebookCredentials,
18
+ resolveGithubCredentials,
19
+ resolveGoogleCredentials,
20
+ resolveSessionSecret,
21
+ } from "../instance/secrets";
22
+ import { baseURLResolver } from "./baseUrl";
23
+
24
+ /**
25
+ * Build the Better-Auth instance for one request, memoized on the request context so the
26
+ * session-resolving middleware and the route handler share a single instance (one secret resolution,
27
+ * one construction). The instance closes over request-scoped state: the audit `emit` seam and the D1
28
+ * binding from `c.env`.
29
+ */
30
+ const cache = new WeakMap<object, Promise<AuthInstance>>();
31
+
32
+ type AuthEnv = SecretsStoreEnv & EmailEnqueueEnv;
33
+
34
+ /** Read the configured D1 binding by name from the worker env, or fail loudly (not a silent undefined). */
35
+ export function resolveDb(env: Record<string, unknown>, bindingName: string): D1Database {
36
+ const binding = env[bindingName];
37
+ if (!binding) {
38
+ throw new InternalError({
39
+ message: "The auth database binding is missing.",
40
+ detail: `D1 binding "${bindingName}" is not present on the worker env`,
41
+ });
42
+ }
43
+ return binding as D1Database;
44
+ }
45
+
46
+ /**
47
+ * The language to write a sign-in email in, for one address.
48
+ *
49
+ * **The stored preference first, the request's negotiation second** (pithy-sh/pithy#441). Somebody who
50
+ * picked a language in a settings pane has said something durable about themselves; the device they are
51
+ * signing in from tonight has only sent an `Accept-Language`. But most of what this is asked about is a
52
+ * *first* sign-in, where no row exists yet — and that is precisely the message a project cannot afford
53
+ * to send in the wrong language, because passwordless has no password to fall back to. So the header's
54
+ * answer, which `@pithy-sh/i18n` has already matched against this project's supported set and put on
55
+ * `c.var.locale`, is what covers that case.
56
+ *
57
+ * One indexed lookup on a unique column, per sign-in email, and only for the two templates that send
58
+ * one. `null` when neither answers, which renders the kit's English — not the same statement as `en`.
59
+ *
60
+ * `c.var.locale` is null unless the i18n capability is composed, so a project that never opted in gets
61
+ * a resolver that reads a column nobody fills and returns null: the behavior it had before any of this.
62
+ */
63
+ function recipientLocale(c: Context<PithyHonoEnv>, db: ReturnType<typeof authDatabase>): ResolveRecipientLocale {
64
+ return async (email) => {
65
+ const row = await db.selectFrom("pithyAuthUsers").select("locale").where("email", "=", email).executeTakeFirst();
66
+ return row?.locale ?? c.var.locale?.catalogLocale ?? null;
67
+ };
68
+ }
69
+
70
+ export function getAuthInstance(c: Context<PithyHonoEnv>, wiring: AuthWiring): Promise<AuthInstance> {
71
+ const existing = cache.get(c);
72
+ if (existing) return existing;
73
+ const built = buildAuthInstance(c, wiring);
74
+ cache.set(c, built);
75
+ return built;
76
+ }
77
+
78
+ async function buildAuthInstance(c: Context<PithyHonoEnv>, wiring: AuthWiring): Promise<AuthInstance> {
79
+ const cfg = wiring.config;
80
+ const enqueueEmail = wiring.enqueueEmail;
81
+ if (!enqueueEmail) {
82
+ throw new InternalError({
83
+ message: "Authentication is misconfigured.",
84
+ detail: "auth.compose did not resolve the email enqueue seam; ensure email() is composed.",
85
+ });
86
+ }
87
+ const env = c.env as unknown as AuthEnv;
88
+ // Secret resolutions hit the same per-invocation cache; run them concurrently. Five reads, and they
89
+ // are not alike — which is the whole of #381.
90
+ //
91
+ // `resolveSessionSecret` is what an auth instance *is*: nothing signs a session without it, so its
92
+ // failure stays a precondition and still fails this call. `resolveDb` below is the same. A provider
93
+ // credential is one sign-in method among several, and it used to sit in this list as an equal — so a
94
+ // single unreadable `auth-github-credentials` rejected the whole `Promise.all` and every magic-link
95
+ // and OTP caller in the deployment got the secrets reader's own refusal instead of a sign-in:
96
+ // measured, `404 secrets/not_found`, message `Secret 'auth-github-credentials' is declared but not
97
+ // provisioned.` So the old behavior named the secret loudly and named it to *the browser*, on a
98
+ // route that has nothing to do with GitHub. Both halves of that are fixed here.
99
+ //
100
+ // `resolveProvider` catches per provider and hands back a state rather than a rejection, so this
101
+ // `Promise.all` settles whenever the session secret does. What the instance then lacks is one
102
+ // provider, and `makeAuth`'s `before` hook answers a caller who asks for it — see `instance/providers.ts`.
103
+ //
104
+ // A held failure belongs to its own secret (#170), so an unreadable provider credential does not
105
+ // disturb the session secret's own read, and a *disabled* provider never calls `.get()` at all.
106
+ const [secret, google, apple, facebook, github] = await Promise.all([
107
+ resolveSessionSecret(env),
108
+ resolveProvider(cfg.google.enabled, () => resolveGoogleCredentials(env)),
109
+ resolveProvider(cfg.apple.enabled, () => resolveAppleCredentials(env)),
110
+ resolveProvider(cfg.facebook.enabled, () => resolveFacebookCredentials(env)),
111
+ resolveProvider(cfg.github.enabled, () => resolveGithubCredentials(env)),
112
+ ]);
113
+ const expiresMinutes = Math.max(1, Math.round(cfg.verificationExpiresIn / 60));
114
+ return makeAuth({
115
+ db: authDatabase(resolveDb(c.env, cfg.database)),
116
+ secret,
117
+ // Never `cfg.baseURL` directly. The instance derives the session cookie's name, the OAuth callback
118
+ // URLs, and the magic-link URL from whatever base URL it is handed, so in a `dev` composition every
119
+ // one of those has to name the address this run is actually serving on — not the production origin
120
+ // the config records. The gate lives in `baseURLResolver` and nowhere else; outside `dev` this is
121
+ // `cfg.baseURL`, unchanged. Resolved here because the instance is itself built per request.
122
+ baseURL: baseURLResolver(cfg.baseURL)(c.req.raw),
123
+ // The language this request negotiated, for the translator plugin. `c.var.locale` is the project's
124
+ // own chain already resolved — never a second negotiation of Better Auth's, which would let the
125
+ // screens and the refusals disagree about who is reading (#452). `null` when nothing negotiated,
126
+ // which is every project that does not compose `i18n`, and which means English.
127
+ locale: c.var.locale?.catalogLocale ?? null,
128
+ basePath: cfg.basePath,
129
+ trustedOrigins: cfg.trustedOrigins,
130
+ google,
131
+ apple,
132
+ facebook,
133
+ github,
134
+ sendEmail: makeSendAuthEmail(
135
+ (input) => enqueueEmail(env, input),
136
+ expiresMinutes,
137
+ recipientLocale(c, authDatabase(resolveDb(c.env, cfg.database))),
138
+ ),
139
+ sessionExpiresIn: cfg.sessionExpiresIn,
140
+ sessionUpdateAge: cfg.sessionUpdateAge,
141
+ verificationExpiresIn: cfg.verificationExpiresIn,
142
+ otpLength: cfg.otpLength,
143
+ disableSignUp: cfg.disableSignUp,
144
+ // Read the emit seam lazily so a later-composed audit capability is honored regardless of the
145
+ // capability order (the instance may be built before audit's middleware runs).
146
+ emit: (event) => c.var.emit(event),
147
+ // The adopter's additional Better Auth plugins, exactly as `auth({ plugins: [...] })` declared them
148
+ // and already checked for additivity at `auth()` call time. The same list the derived migrations
149
+ // were built from — the routes a plugin serves and the tables it needs come from one declaration.
150
+ plugins: cfg.plugins,
151
+ });
152
+ }