@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,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
|
+
}
|