@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,199 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Locale } from "@pithy-sh/core/src/i18n/locale";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { DevicePlatform } from "../data/device";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the auth admin routes return, as Zod objects a management client can validate against.
|
|
10
|
+
*
|
|
11
|
+
* `schemas.ts` bounds what a caller may send; this file states what it gets back. Both halves are
|
|
12
|
+
* runtime values for the same reason: a management client reading a customer's Worker is crossing a
|
|
13
|
+
* trust boundary, its own rules require it to validate that response before rendering it, and a
|
|
14
|
+
* TypeScript interface is erased before it can help. Every client that had only an interface
|
|
15
|
+
* hand-wrote a mirror of these, and the mirror drifted the first time a field landed here.
|
|
16
|
+
*
|
|
17
|
+
* **No codecs, and no transform anywhere in this file.** These describe JSON on the wire, so parsing
|
|
18
|
+
* one hands back exactly what went in — which is what lets `responses.test.ts` compare a parsed value
|
|
19
|
+
* with the projection's output and fail on a field either side forgot. A `SQLiteDate` here would
|
|
20
|
+
* decode an ISO string into a `Date` and make that comparison meaningless.
|
|
21
|
+
*
|
|
22
|
+
* The projections that fill these live in `views.ts`, which documents *why* a credential is absent
|
|
23
|
+
* from each. This file is the shape; that file is the argument.
|
|
24
|
+
*
|
|
25
|
+
* **A field added here later is `.optional()`, not merely `.nullable()`.** This module is read across a
|
|
26
|
+
* version boundary — a management client validates a response with this schema against a customer's
|
|
27
|
+
* Worker at whatever kit version it is on — so an additive required key fails `safeParse` for everyone
|
|
28
|
+
* below that release and takes the whole pane with it (#450). Absent then means *this Worker cannot
|
|
29
|
+
* say*, which is a different fact from `null`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Where a page resumes, or the end of the list. */
|
|
33
|
+
const NextCursor = z
|
|
34
|
+
.string()
|
|
35
|
+
.nullable()
|
|
36
|
+
.describe("Where the next page resumes. Null at the end of the list. Opaque — pass it back verbatim.");
|
|
37
|
+
|
|
38
|
+
/** How many sessions a revocation actually ended. */
|
|
39
|
+
const RevokedCount = z
|
|
40
|
+
.number()
|
|
41
|
+
.int()
|
|
42
|
+
.min(0)
|
|
43
|
+
.describe("How many sessions were revoked. Zero is a success: revoking nothing is the idempotent case.");
|
|
44
|
+
|
|
45
|
+
/** A user as a management client may see them. */
|
|
46
|
+
export const AdminUserView = z
|
|
47
|
+
.object({
|
|
48
|
+
id: z.string().describe("The user's id, as `pithy_auth_users.id` stores it."),
|
|
49
|
+
email: z.string().describe("The user's email address. Personal data, and the point of a support pane."),
|
|
50
|
+
name: z.string().describe("The display name from the provider profile or sign-up."),
|
|
51
|
+
emailVerified: z.boolean().describe("Whether the address has been verified."),
|
|
52
|
+
image: z.string().nullable().describe("The avatar URL from a social profile, or null."),
|
|
53
|
+
// `Locale`, not a bare string: it is a plain schema with checks and no transform, so parsing a
|
|
54
|
+
// response still hands back exactly what went in — which is what `responses.test.ts` compares.
|
|
55
|
+
locale: Locale.nullable()
|
|
56
|
+
.optional()
|
|
57
|
+
.describe(
|
|
58
|
+
"The reader's chosen language as a BCP-47 tag; null when they have never chosen one, and absent when the Worker that answered predates the field (#450).",
|
|
59
|
+
),
|
|
60
|
+
createdAt: z.iso.datetime().describe("When the user was created, ISO-8601."),
|
|
61
|
+
updatedAt: z.iso.datetime().describe("When the row was last written, ISO-8601."),
|
|
62
|
+
})
|
|
63
|
+
.describe("One user as a management client sees them. The table holds no credential, so nothing is withheld.");
|
|
64
|
+
export type AdminUserView = z.output<typeof AdminUserView>;
|
|
65
|
+
|
|
66
|
+
/** A session as a management client may see it — the token is not part of the contract. */
|
|
67
|
+
export const AdminSessionView = z
|
|
68
|
+
.object({
|
|
69
|
+
id: z.string().describe("The session's id — the handle `POST /admin/sessions/revoke` accepts. Not the token."),
|
|
70
|
+
deviceId: z.string().nullable().describe("The device this session was created on, or null."),
|
|
71
|
+
ipAddress: z.string().nullable().describe("Where the sign-in came from, or null. The field that catches a theft."),
|
|
72
|
+
userAgent: z.string().nullable().describe("The client user-agent at sign-in, or null."),
|
|
73
|
+
createdAt: z.iso.datetime().describe("When the session began, ISO-8601."),
|
|
74
|
+
updatedAt: z.iso.datetime().describe("When it was last refreshed, ISO-8601."),
|
|
75
|
+
expiresAt: z.iso.datetime().describe("When it lapses, ISO-8601."),
|
|
76
|
+
})
|
|
77
|
+
.describe("One session as a management client sees it — without the token, which is the credential itself.");
|
|
78
|
+
export type AdminSessionView = z.output<typeof AdminSessionView>;
|
|
79
|
+
|
|
80
|
+
/** A registered device as a management client may see it — the push token is not part of the contract. */
|
|
81
|
+
export const AdminDeviceView = z
|
|
82
|
+
.object({
|
|
83
|
+
id: z.string().describe("The client-generated device id it registered at sign-in."),
|
|
84
|
+
userId: z.string().describe("The owning user's id."),
|
|
85
|
+
platform: DevicePlatform.describe("The platform the device registered as."),
|
|
86
|
+
name: z.string().nullable().describe("The device's human label, or null."),
|
|
87
|
+
model: z.string().nullable().describe("The hardware model, or null."),
|
|
88
|
+
osVersion: z.string().nullable().describe("The device OS version at last sign-in, or null."),
|
|
89
|
+
appVersion: z.string().nullable().describe("The client app version at last sign-in, or null."),
|
|
90
|
+
lastIp: z.string().nullable().describe("Where the device was last seen, or null."),
|
|
91
|
+
lastSeenAt: z.iso.datetime().describe("When it was last seen, ISO-8601."),
|
|
92
|
+
createdAt: z.iso.datetime().describe("When it first registered, ISO-8601."),
|
|
93
|
+
})
|
|
94
|
+
.describe("One registered device as a management client sees it — without the push token, which is a credential.");
|
|
95
|
+
export type AdminDeviceView = z.output<typeof AdminDeviceView>;
|
|
96
|
+
|
|
97
|
+
/** `GET {base}/admin/users`. */
|
|
98
|
+
export const AdminUsersResponse = z
|
|
99
|
+
.object({
|
|
100
|
+
users: z.array(AdminUserView).describe("The page, newest first."),
|
|
101
|
+
nextCursor: NextCursor,
|
|
102
|
+
})
|
|
103
|
+
.describe("A page of the user list.");
|
|
104
|
+
export type AdminUsersResponse = z.output<typeof AdminUsersResponse>;
|
|
105
|
+
|
|
106
|
+
/** `GET {base}/admin/users/:userId`. */
|
|
107
|
+
/**
|
|
108
|
+
* A sub-read of the user pane that could not be made (#380).
|
|
109
|
+
*
|
|
110
|
+
* The pane fans out over three independent tables and this route used to `Promise.all` them: one D1 read
|
|
111
|
+
* failing took the whole page down, so a support agent looking at a locked-out account saw a 500 instead
|
|
112
|
+
* of the user and whichever lists did read.
|
|
113
|
+
*
|
|
114
|
+
* It carries **no rows and no reason**. No rows, because an empty array means *this user has none* and a
|
|
115
|
+
* pane rendering "no active sessions" over a list nobody read is telling a support agent something that
|
|
116
|
+
* was never established. No reason, because what a D1 read throws names a query and a table, and this
|
|
117
|
+
* response crosses a trust boundary to a management client.
|
|
118
|
+
*/
|
|
119
|
+
const ListUnavailable = z
|
|
120
|
+
.object({ state: z.literal("unavailable").describe("The list could not be read on this request.") })
|
|
121
|
+
.describe("Nothing was established about this list. Deliberately empty — there is nothing here to render as 'none'.");
|
|
122
|
+
|
|
123
|
+
/** A bounded sub-list, behind its state: the rows and the truncation flag are unreachable without narrowing. */
|
|
124
|
+
const boundedList = <T extends z.ZodTypeAny>(items: T, what: string) =>
|
|
125
|
+
z
|
|
126
|
+
.discriminatedUnion("state", [
|
|
127
|
+
z
|
|
128
|
+
.object({
|
|
129
|
+
state: z.literal("read").describe("The list was read."),
|
|
130
|
+
items: z.array(items).describe(`${what} Empty means this user has none.`),
|
|
131
|
+
truncated: z
|
|
132
|
+
.boolean()
|
|
133
|
+
.describe(
|
|
134
|
+
"True when more rows exist than the bound allowed. A pane must say so rather than imply a total.",
|
|
135
|
+
),
|
|
136
|
+
})
|
|
137
|
+
.describe("The rows, and whether the bound cut them short."),
|
|
138
|
+
ListUnavailable,
|
|
139
|
+
])
|
|
140
|
+
.describe(`${what} Behind a state, so an unread list cannot be rendered as an empty one.`);
|
|
141
|
+
|
|
142
|
+
/** An unbounded sub-list, behind the same state. No truncation flag: this read has no bound to exceed. */
|
|
143
|
+
const wholeList = <T extends z.ZodTypeAny>(items: T, what: string) =>
|
|
144
|
+
z
|
|
145
|
+
.discriminatedUnion("state", [
|
|
146
|
+
z
|
|
147
|
+
.object({
|
|
148
|
+
state: z.literal("read").describe("The list was read."),
|
|
149
|
+
items: z.array(items).describe(`${what} Empty means this user has none.`),
|
|
150
|
+
})
|
|
151
|
+
.describe("The rows, in full."),
|
|
152
|
+
ListUnavailable,
|
|
153
|
+
])
|
|
154
|
+
.describe(`${what} Behind a state, so an unread list cannot be rendered as an empty one.`);
|
|
155
|
+
|
|
156
|
+
export const AdminUserResponse = z
|
|
157
|
+
.object({
|
|
158
|
+
user: AdminUserView.describe("The user."),
|
|
159
|
+
providers: wholeList(
|
|
160
|
+
z.string(),
|
|
161
|
+
"The OAuth providers linked to this account, as slugs. Read by a query that selects only `providerId`, so no provider token is ever loaded.",
|
|
162
|
+
),
|
|
163
|
+
sessions: boundedList(AdminSessionView, "Their live sessions, newest first, bounded."),
|
|
164
|
+
devices: boundedList(AdminDeviceView, "Their registered devices, most recently seen first, bounded."),
|
|
165
|
+
})
|
|
166
|
+
.describe(
|
|
167
|
+
"One user with their live sessions, registered devices, and linked providers. The user is the subject and its absence is a 404; the three lists are contributors, and one that will not read costs its own list and not the page (#380).",
|
|
168
|
+
);
|
|
169
|
+
export type AdminUserResponse = z.output<typeof AdminUserResponse>;
|
|
170
|
+
|
|
171
|
+
/** `GET {base}/admin/devices`. */
|
|
172
|
+
export const AdminDevicesResponse = z
|
|
173
|
+
.object({
|
|
174
|
+
devices: z.array(AdminDeviceView).describe("The page, most recently seen first."),
|
|
175
|
+
nextCursor: NextCursor,
|
|
176
|
+
})
|
|
177
|
+
.describe("A page of the device registry.");
|
|
178
|
+
export type AdminDevicesResponse = z.output<typeof AdminDevicesResponse>;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `POST {base}/admin/sessions/revoke` and `POST {base}/admin/users/:userId/sessions/revoke`.
|
|
182
|
+
*
|
|
183
|
+
* A count and nothing else, deliberately. Both routes are idempotent — revoking a session that has
|
|
184
|
+
* already gone is a success — and neither caller holds a read scope, so the response must not say
|
|
185
|
+
* whose session it was. The owning user reaches the audit trail instead.
|
|
186
|
+
*/
|
|
187
|
+
export const AdminRevokeResponse = z
|
|
188
|
+
.object({ revoked: RevokedCount })
|
|
189
|
+
.describe("How many sessions the revocation ended.");
|
|
190
|
+
export type AdminRevokeResponse = z.output<typeof AdminRevokeResponse>;
|
|
191
|
+
|
|
192
|
+
/** `POST {base}/admin/users/:userId/devices/revoke`. */
|
|
193
|
+
export const AdminDeviceRevokeResponse = z
|
|
194
|
+
.object({
|
|
195
|
+
revoked: RevokedCount,
|
|
196
|
+
removed: z.boolean().describe("Whether a device row was deleted. False when the user had no such device."),
|
|
197
|
+
})
|
|
198
|
+
.describe("How many sessions the device revocation ended, and whether the registry row went with them.");
|
|
199
|
+
export type AdminDeviceRevokeResponse = z.output<typeof AdminDeviceRevokeResponse>;
|
|
@@ -0,0 +1,325 @@
|
|
|
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 { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { requireSameOrigin } from "@pithy-sh/core/src/http/sameOrigin";
|
|
8
|
+
import { validationHook } from "@pithy-sh/core/src/http/validation";
|
|
9
|
+
import { TURNSTILE_LOGIN_ACTION } from "@pithy-sh/turnstile/src/config/config";
|
|
10
|
+
import { turnstile } from "@pithy-sh/turnstile/src/http/middleware";
|
|
11
|
+
import type { Context, Hono } from "hono";
|
|
12
|
+
import { correlation, emitDenied, emitDeviceRevoked, emitTokenRefresh, emitTokenReuseDetected } from "../audit/emit";
|
|
13
|
+
import type { AuthWiring } from "../capability";
|
|
14
|
+
import { authDatabase } from "../data/tables";
|
|
15
|
+
import { deleteDevice, deviceSessionTokens, listDevices } from "../device/registry";
|
|
16
|
+
import {
|
|
17
|
+
consumeSession,
|
|
18
|
+
findConsumedToken,
|
|
19
|
+
pruneConsumedTokens,
|
|
20
|
+
recordConsumedToken,
|
|
21
|
+
revokeFamily,
|
|
22
|
+
} from "../token/rotation";
|
|
23
|
+
import { registerAuthAdminRoutes } from "./adminRoutes";
|
|
24
|
+
import { apiErrorToPithy } from "./errors";
|
|
25
|
+
import { requireAuth } from "./middleware";
|
|
26
|
+
import { getAuthInstance, resolveDb } from "./resolve";
|
|
27
|
+
import { RevokeDeviceBody } from "./schemas";
|
|
28
|
+
|
|
29
|
+
type Ctx = Context<PithyHonoEnv>;
|
|
30
|
+
|
|
31
|
+
function db(c: Ctx, wiring: AuthWiring) {
|
|
32
|
+
return authDatabase(resolveDb(c.env, wiring.config.database));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Register the auth routes onto the backend's Hono app.
|
|
37
|
+
*
|
|
38
|
+
* Order matters: turnstile gates the human-initiated send routes (only when composed), Pithy's own
|
|
39
|
+
* routes are registered before the catch-all so they win, and Better Auth owns everything else under
|
|
40
|
+
* `basePath`. A handler that returns a Response ends the chain, so the specific routes never fall
|
|
41
|
+
* through to the catch-all. (The tier-1 edge rate limiter is contributed as capability *middleware*, so
|
|
42
|
+
* it runs before session resolution — see `capability.ts`.)
|
|
43
|
+
*
|
|
44
|
+
* What each Pithy-owned route accepts, declared on its route line (schemas in `./schemas`):
|
|
45
|
+
*
|
|
46
|
+
* | Route | Verification | Input |
|
|
47
|
+
* | -------------------------- | --------------- | --------------------------- |
|
|
48
|
+
* | `POST /token/rotate` | bearer/session | none — the credential only |
|
|
49
|
+
* | `GET /devices` | bearer/session | none |
|
|
50
|
+
* | `POST /devices/revoke` | bearer/session | json `RevokeDeviceBody` |
|
|
51
|
+
* | `* /admin/*` | control-plane | see `./adminRoutes` |
|
|
52
|
+
*
|
|
53
|
+
* The catch-all takes NO validator, deliberately: `handleBetterAuth` hands Better Auth `c.req.raw`,
|
|
54
|
+
* and reading the body first would consume the stream. Better Auth validates its own endpoints and
|
|
55
|
+
* answers its own refusals; what it rejects reaches a caller in its shape, not ours.
|
|
56
|
+
*
|
|
57
|
+
* **The admin routes must be registered before the catch-all, and it is not a style preference.**
|
|
58
|
+
* `handleBetterAuth` returns a Response, which ends the chain — so a route registered after
|
|
59
|
+
* `app.all(`${base}/*`)` is mounted, is visible in `app.routes`, and never runs. A management call to
|
|
60
|
+
* `/auth/admin/users` would instead reach Better Auth, which knows no such endpoint, and the failure
|
|
61
|
+
* would look like a 404 from the wrong layer. `routeContract.test.ts` proves the ordering with a real
|
|
62
|
+
* request rather than by inspecting the route table, because the route table cannot see this.
|
|
63
|
+
*/
|
|
64
|
+
export function createAuthRoutes(wiring: AuthWiring): (app: Hono<PithyHonoEnv>) => void {
|
|
65
|
+
return (app) => {
|
|
66
|
+
const base = wiring.config.basePath;
|
|
67
|
+
// The CSRF origin guard for our own mutating routes (Better Auth guards its own endpoints). The
|
|
68
|
+
// same gate an adopter's routes wear: this capability publishes it bound to the origins it
|
|
69
|
+
// resolved, and reads it back here rather than binding a second copy of the same decision.
|
|
70
|
+
const csrf = requireSameOrigin();
|
|
71
|
+
|
|
72
|
+
// Auto-gate the magic-link and OTP send routes with the humanity check, when turnstile is composed.
|
|
73
|
+
// The action is `@pithy-sh/turnstile`'s constant, never a literal: the widget is solved for the same
|
|
74
|
+
// string through the client projection, and a second copy of it could only be caught in production —
|
|
75
|
+
// where the two disagreeing refuses every sign-in. #377, and `TURNSTILE_LOGIN_ACTION`'s docblock.
|
|
76
|
+
if (wiring.turnstile) {
|
|
77
|
+
const guard = turnstile({ mode: wiring.turnstile.mode, action: TURNSTILE_LOGIN_ACTION });
|
|
78
|
+
app.use(`${base}/sign-in/magic-link`, guard);
|
|
79
|
+
app.use(`${base}/email-otp/send-verification-otp`, guard);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Rotate the refresh credential: mint a new session, revoke the presented one, return a fresh JWT.
|
|
83
|
+
app.post(`${base}/token/rotate`, csrf, (c) => rotateToken(c, wiring));
|
|
84
|
+
// Device management (bearer/session gated; the revoke is CSRF-guarded as a mutating route).
|
|
85
|
+
app.get(`${base}/devices`, requireAuth(), (c) => listMyDevices(c, wiring));
|
|
86
|
+
app.post(`${base}/devices/revoke`, requireAuth(), csrf, zValidator("json", RevokeDeviceBody, validationHook), (c) =>
|
|
87
|
+
revokeMyDevice(c, wiring, c.req.valid("json")),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// The control-plane management surface. Registered here, before the catch-all below, or it is dead.
|
|
91
|
+
registerAuthAdminRoutes(wiring)(app);
|
|
92
|
+
|
|
93
|
+
// Better Auth owns the rest (sign-in, verify, callback, sign-out, /token, /jwks, revoke-sessions…).
|
|
94
|
+
app.all(`${base}/*`, (c) => handleBetterAuth(c, wiring));
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The statuses that mean somebody was turned away, rather than that something is broken. */
|
|
99
|
+
const DENIED_STATUSES = new Set([400, 401, 403]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether a refused request on this path was an attempt to authenticate.
|
|
103
|
+
*
|
|
104
|
+
* **The audit action is `auth/signin`, so the path has to earn it.** `emitDenied` records
|
|
105
|
+
* `auth/signin outcome=denied actorType=anonymous`, which is the row a brute-force alert counts — and
|
|
106
|
+
* the catch-all under `basePath` carries far more than sign-in. Recording every 4xx there would write
|
|
107
|
+
* a *failed sign-in* for a logged-out tab polling `/update-user` with a stale cookie, and would let an
|
|
108
|
+
* unauthenticated loop against `/list-sessions` bury real credential-stuffing under noise wearing the
|
|
109
|
+
* same anonymous shape.
|
|
110
|
+
*
|
|
111
|
+
* So: the routes where presenting something and being refused *is* the failed attempt — starting a
|
|
112
|
+
* sign-in, completing one at an OAuth callback, and asking for the credential that starts one.
|
|
113
|
+
*/
|
|
114
|
+
function isSignInAttempt(path: string): boolean {
|
|
115
|
+
return (
|
|
116
|
+
path.includes("/sign-in/") ||
|
|
117
|
+
path.includes("/callback/") ||
|
|
118
|
+
path.includes("/magic-link/") ||
|
|
119
|
+
path.includes("/email-otp/")
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Record a Better Auth refusal on the audit trail.
|
|
125
|
+
*
|
|
126
|
+
* **Read off the Response, because a refusal never arrives as a throw.** `onAPIError: { throw: true }`
|
|
127
|
+
* reads as though an endpoint's `APIError` reaches this module, and it does not: better-auth's
|
|
128
|
+
* `onError` re-raises it (`better-auth/dist/api/index.mjs:193`) directly into better-call's own catch
|
|
129
|
+
* (`better-call@1.4.0`, `dist/router.mjs:83-89`), which renders an `APIError` as a Response and returns
|
|
130
|
+
* it. The throw is swallowed one frame later by the library that asked for it.
|
|
131
|
+
*
|
|
132
|
+
* So the `emitDenied` call this replaces — gated on catching an `APIError` — had never run once. No
|
|
133
|
+
* failed one-time code, no bad magic link, no refused OAuth callback has ever reached
|
|
134
|
+
* `pithy_audit_events`, which is the largest class of security event this capability has (#449).
|
|
135
|
+
*
|
|
136
|
+
* **The body is cloned, never consumed.** The Response is handed back to the caller untouched: Better
|
|
137
|
+
* Auth's own flat shape is a contract adopters read through `createAuthClient`, and the browser client
|
|
138
|
+
* reads it too, so nothing here rewrites it.
|
|
139
|
+
*/
|
|
140
|
+
async function auditRefusal(c: Ctx, response: Response): Promise<void> {
|
|
141
|
+
if (!DENIED_STATUSES.has(response.status)) return;
|
|
142
|
+
if (!isSignInAttempt(new URL(c.req.raw.url).pathname)) return;
|
|
143
|
+
|
|
144
|
+
let body: unknown;
|
|
145
|
+
try {
|
|
146
|
+
body = await response.clone().json();
|
|
147
|
+
} catch {
|
|
148
|
+
// A body that will not parse still refused somebody, but names no reason worth a row.
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const code = (body as { code?: unknown } | null)?.code;
|
|
152
|
+
|
|
153
|
+
// Record only the Better-Auth error *code* (e.g. INVALID_OTP) — never the message, which can
|
|
154
|
+
// carry the submitted email or other request context (no PII in the audit trail).
|
|
155
|
+
await emitDenied(c.var.emit, {
|
|
156
|
+
...correlation(c.req.raw.headers),
|
|
157
|
+
detail: typeof code === "string" ? code : undefined,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Delegate to Better Auth's fetch handler, and record what it refused.
|
|
163
|
+
*
|
|
164
|
+
* **The answer is returned exactly as Better Auth wrote it.** Re-homing its flat `{ message, code }`
|
|
165
|
+
* into the kit envelope was tried and taken back out: `packages/auth/README.md` documents
|
|
166
|
+
* `createAuthClient` from `better-auth/client` as a first-class client surface (#271), and
|
|
167
|
+
* `@better-fetch/fetch` builds its error as `{ ...parsedBody, status }` — so rewriting the body would
|
|
168
|
+
* make `error.code` `undefined` for every adopter on the documented path. The wire is Better Auth's
|
|
169
|
+
* contract. `readFailure` in `../client/api` learns to read it instead (#449).
|
|
170
|
+
*
|
|
171
|
+
* The `catch` covers what better-call genuinely hands on: a non-`APIError` throw from an endpoint
|
|
172
|
+
* (`router.mjs:88`), and a throw from a plugin's `onRequest` hook, which runs outside the router's own
|
|
173
|
+
* try. A failure building the instance does **not** come this way — `getAuthInstance` is called before
|
|
174
|
+
* it, and what that throws is already a `PithyError` for `pithyErrorHandler` to render.
|
|
175
|
+
*/
|
|
176
|
+
async function handleBetterAuth(c: Ctx, wiring: AuthWiring): Promise<Response> {
|
|
177
|
+
const instance = await getAuthInstance(c, wiring);
|
|
178
|
+
let response: Response;
|
|
179
|
+
try {
|
|
180
|
+
response = await instance.handler(c.req.raw);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw apiErrorToPithy(error);
|
|
183
|
+
}
|
|
184
|
+
await auditRefusal(c, response);
|
|
185
|
+
return response;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The raw bearer token presented on the Authorization header, or undefined when none/malformed. */
|
|
189
|
+
function bearerToken(headers: Headers): string | undefined {
|
|
190
|
+
const match = headers.get("authorization")?.match(/^Bearer\s+(.+)$/i);
|
|
191
|
+
return match?.[1]?.trim() || undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Grace window after a token is consumed during which a replay is treated as a benign concurrent or
|
|
196
|
+
* retried rotation — denied, but WITHOUT revoking the family — rather than compromise. It absorbs a
|
|
197
|
+
* client that fires or retries the same rotation twice (a network race) so a legitimate double-submit
|
|
198
|
+
* never signs the user out everywhere. A replay past the window is a genuine reuse and revokes.
|
|
199
|
+
*/
|
|
200
|
+
const ROTATION_REUSE_GRACE_MS = 30_000;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Rotate the refresh credential. Validates the presented session, mints a fresh one (preserving the
|
|
204
|
+
* device binding and refresh-token family), atomically revokes the old one, and returns a new access
|
|
205
|
+
* token + refresh token. This is the "refresh credential rotates on use" primitive Better Auth does not
|
|
206
|
+
* provide natively.
|
|
207
|
+
*
|
|
208
|
+
* Two hardenings over a naive rotate (#59):
|
|
209
|
+
* - **Reuse detection.** A presented token that no longer resolves to a live session but was previously
|
|
210
|
+
* consumed is a replayed refresh token — the compromise signal for rotated refresh tokens
|
|
211
|
+
* (RFC 6819 §5.2.2.3). The whole family is revoked and the attempt denied. (Covers the bearer refresh
|
|
212
|
+
* flow — the mobile refresh credential; a cookie session presented after rotation already fails closed.)
|
|
213
|
+
* - **Race safety.** The old session is consumed by a conditional delete that exactly one concurrent
|
|
214
|
+
* rotation wins; the loser rolls back its freshly-minted successor. One presented token, one successor.
|
|
215
|
+
*/
|
|
216
|
+
async function rotateToken(c: Ctx, wiring: AuthWiring): Promise<Response> {
|
|
217
|
+
const instance = await getAuthInstance(c, wiring);
|
|
218
|
+
const headers = c.req.raw.headers;
|
|
219
|
+
const database = db(c, wiring);
|
|
220
|
+
const ctx = await instance.$context;
|
|
221
|
+
const current = await instance.api.getSession({ headers });
|
|
222
|
+
|
|
223
|
+
if (!current) {
|
|
224
|
+
// The presented token resolves to no live session. If it was consumed by an earlier rotation, it is
|
|
225
|
+
// a replay: compromise past the grace window (revoke the family), or a benign concurrent/retried
|
|
226
|
+
// rotation within it (deny only). Otherwise the token is simply invalid or expired.
|
|
227
|
+
const presented = bearerToken(headers);
|
|
228
|
+
const consumed = presented ? await findConsumedToken(database, presented) : null;
|
|
229
|
+
if (consumed) {
|
|
230
|
+
if (Date.now() - consumed.rotatedAt.getTime() >= ROTATION_REUSE_GRACE_MS) {
|
|
231
|
+
await revokeFamily(database, consumed.familyId, (token) => ctx.internalAdapter.deleteSession(token));
|
|
232
|
+
await emitTokenReuseDetected(c.var.emit, {
|
|
233
|
+
userId: consumed.userId,
|
|
234
|
+
familyId: consumed.familyId,
|
|
235
|
+
...correlation(headers),
|
|
236
|
+
});
|
|
237
|
+
throw new UnauthorizedError({
|
|
238
|
+
message: "This credential has been revoked.",
|
|
239
|
+
action: "Sign in again to obtain a new session.",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// Within the grace window: a superseded token from a race/retry. Deny, but leave the family — the
|
|
243
|
+
// successor the winning rotation issued must keep working.
|
|
244
|
+
throw new UnauthorizedError({
|
|
245
|
+
message: "Authentication required.",
|
|
246
|
+
action: "Retry with your current session or bearer token.",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
throw new UnauthorizedError({
|
|
250
|
+
message: "Authentication required.",
|
|
251
|
+
action: "Present a valid session or bearer token to rotate.",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const ip = headers.get("cf-connecting-ip") ?? undefined;
|
|
256
|
+
const userAgent = headers.get("user-agent") ?? undefined;
|
|
257
|
+
const session = current.session as { token: string; deviceId?: string | null; familyId?: string | null };
|
|
258
|
+
const deviceId = session.deviceId ?? undefined;
|
|
259
|
+
// Carry the family forward across the rotation; a session that never had one starts a family now.
|
|
260
|
+
const familyId = session.familyId ?? crypto.randomUUID();
|
|
261
|
+
|
|
262
|
+
// Mint the successor session and its access token BEFORE consuming the old one, so a transient signing
|
|
263
|
+
// failure leaves the presented refresh token still valid (the successor simply expires unused) rather
|
|
264
|
+
// than stranding the caller with no working credential.
|
|
265
|
+
const next = await ctx.internalAdapter.createSession(current.user.id, undefined, {
|
|
266
|
+
ipAddress: ip,
|
|
267
|
+
userAgent,
|
|
268
|
+
familyId,
|
|
269
|
+
...(deviceId ? { deviceId } : {}),
|
|
270
|
+
});
|
|
271
|
+
const access = await instance.api.getToken({ headers: new Headers({ authorization: `Bearer ${next.token}` }) });
|
|
272
|
+
|
|
273
|
+
// Atomically consume the presented session. Of N concurrent rotations, exactly one wins the delete;
|
|
274
|
+
// the losers roll back the successor they minted, so one presented token never yields two successors.
|
|
275
|
+
const consumed = await consumeSession(database, session.token);
|
|
276
|
+
if (!consumed.won) {
|
|
277
|
+
await ctx.internalAdapter.deleteSession(next.token);
|
|
278
|
+
throw new UnauthorizedError({
|
|
279
|
+
message: "Authentication required.",
|
|
280
|
+
action: "Retry with your current session or bearer token.",
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Record the consumed token so a later replay is caught as reuse (the family is already carried forward).
|
|
285
|
+
await recordConsumedToken(database, {
|
|
286
|
+
token: session.token,
|
|
287
|
+
familyId,
|
|
288
|
+
userId: current.user.id,
|
|
289
|
+
rotatedAt: new Date(),
|
|
290
|
+
});
|
|
291
|
+
await emitTokenRefresh(c.var.emit, { userId: current.user.id, sessionId: next.id, ip, userAgent });
|
|
292
|
+
|
|
293
|
+
// Bound the ledger: a token consumed longer ago than a full session lifetime can no longer match any
|
|
294
|
+
// live session. Best-effort — a cleanup failure must never fail the rotation it rode in on.
|
|
295
|
+
try {
|
|
296
|
+
await pruneConsumedTokens(database, new Date(Date.now() - wiring.config.sessionExpiresIn * 1000));
|
|
297
|
+
} catch {
|
|
298
|
+
// Retention pruning is non-critical maintenance.
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return c.json({ accessToken: access.token, refreshToken: next.token, expiresAt: next.expiresAt });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** List the authenticated user's registered devices. */
|
|
305
|
+
async function listMyDevices(c: Ctx, wiring: AuthWiring): Promise<Response> {
|
|
306
|
+
const userId = c.var.auth?.userId;
|
|
307
|
+
if (!userId) throw new UnauthorizedError({ message: "Authentication required." });
|
|
308
|
+
return c.json({ devices: await listDevices(db(c, wiring), userId) });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Revoke one of the authenticated user's devices: sign out its sessions, then drop the device row. */
|
|
312
|
+
async function revokeMyDevice(c: Ctx, wiring: AuthWiring, body: RevokeDeviceBody): Promise<Response> {
|
|
313
|
+
const userId = c.var.auth?.userId;
|
|
314
|
+
if (!userId) throw new UnauthorizedError({ message: "Authentication required." });
|
|
315
|
+
const deviceId = body.deviceId;
|
|
316
|
+
const database = db(c, wiring);
|
|
317
|
+
const tokens = await deviceSessionTokens(database, userId, deviceId);
|
|
318
|
+
const ctx = await (await getAuthInstance(c, wiring)).$context;
|
|
319
|
+
for (const token of tokens) {
|
|
320
|
+
await ctx.internalAdapter.deleteSession(token);
|
|
321
|
+
}
|
|
322
|
+
await deleteDevice(database, userId, deviceId);
|
|
323
|
+
await emitDeviceRevoked(c.var.emit, { userId, ...correlation(c.req.raw.headers) });
|
|
324
|
+
return c.json({ revoked: tokens.length });
|
|
325
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { MAX_PAGE_SIZE } from "@pithy-sh/core/src/data/cursor";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { DevicePlatform } from "../data/device";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The request schemas for the auth routes Pithy owns. Validation happens at the HTTP boundary
|
|
10
|
+
* (CLAUDE.md §Zod), declared on the route line with `zValidator(target, Schema, validationHook)` — so
|
|
11
|
+
* reading `routes.ts` and `adminRoutes.ts` tells you what each route accepts without opening a handler.
|
|
12
|
+
*
|
|
13
|
+
* Only Pithy's own routes appear here. Everything under `basePath` that Better Auth owns validates
|
|
14
|
+
* itself, behind a catch-all that must hand it an unread request body.
|
|
15
|
+
*
|
|
16
|
+
* Every schema below the first is a bound on something a **management client** chose. Verified is not
|
|
17
|
+
* the same as trusted: a control-plane credential proves who is calling, not that their pagination is
|
|
18
|
+
* sane, and a client with a bug asks for a million user rows exactly as easily as a hostile one does.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The body of `POST /devices/revoke` and of the admin `POST /admin/users/:userId/devices/revoke`.
|
|
23
|
+
* `deviceId` is client-minted (it arrives as the `x-pithy-device-id` header at sign-in), so it is
|
|
24
|
+
* bounded rather than shape-checked: a well-formed id that matches no row must still reach the handler,
|
|
25
|
+
* which answers `{ revoked: 0 }`.
|
|
26
|
+
*/
|
|
27
|
+
export const RevokeDeviceBody = z
|
|
28
|
+
.object({
|
|
29
|
+
deviceId: z
|
|
30
|
+
.string()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(256)
|
|
33
|
+
.describe("The id of the device to revoke — the client-generated id it registered at sign-in."),
|
|
34
|
+
})
|
|
35
|
+
.describe("The body identifying which device to revoke — the caller's own, or an admin-named user's.");
|
|
36
|
+
export type RevokeDeviceBody = z.output<typeof RevokeDeviceBody>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The user id in the path of every single-user admin route.
|
|
40
|
+
*
|
|
41
|
+
* Bounded rather than `.uuid()`: Better Auth's id generator is configurable and its default is not a
|
|
42
|
+
* UUID, so a UUID shape here would 404 — as a 400 — every user in a project that changed it. The schema
|
|
43
|
+
* constrains the string; the handler still does the lookup and raises its own `core/not_found`.
|
|
44
|
+
*/
|
|
45
|
+
export const UserIdParam = z
|
|
46
|
+
.object({
|
|
47
|
+
userId: z.string().min(1).max(255).describe("The user's id, as `pithy_auth_users.id` stores it."),
|
|
48
|
+
})
|
|
49
|
+
.describe("The path parameters of every admin route that names one user.");
|
|
50
|
+
export type UserIdParam = z.output<typeof UserIdParam>;
|
|
51
|
+
|
|
52
|
+
/** How many rows a paged admin listing returns, when the caller names a number. */
|
|
53
|
+
const PageLimit = z.coerce
|
|
54
|
+
.number()
|
|
55
|
+
.int()
|
|
56
|
+
.min(1)
|
|
57
|
+
.max(MAX_PAGE_SIZE)
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("How many rows to return. Bounded, because a verified client can still have a bug.");
|
|
60
|
+
|
|
61
|
+
/** Where a paged admin listing resumes. */
|
|
62
|
+
const PageCursorParam = z
|
|
63
|
+
.string()
|
|
64
|
+
.max(512)
|
|
65
|
+
.optional()
|
|
66
|
+
.describe("Where to resume, from the previous page's `nextCursor`. Opaque; a malformed one is a first page.");
|
|
67
|
+
|
|
68
|
+
/** The user listing query. */
|
|
69
|
+
export const ListUsersQuery = z
|
|
70
|
+
.object({
|
|
71
|
+
search: z
|
|
72
|
+
.string()
|
|
73
|
+
.min(1)
|
|
74
|
+
.max(200)
|
|
75
|
+
.optional()
|
|
76
|
+
.describe(
|
|
77
|
+
"Free text matched against email and display name. `%` and `_` are escaped rather than treated as wildcards, so an address containing an underscore matches itself and nothing else.",
|
|
78
|
+
),
|
|
79
|
+
cursor: PageCursorParam,
|
|
80
|
+
limit: PageLimit,
|
|
81
|
+
})
|
|
82
|
+
.describe("The user listing query: what to search for, how many to return, and where to resume.");
|
|
83
|
+
export type ListUsersQuery = z.output<typeof ListUsersQuery>;
|
|
84
|
+
|
|
85
|
+
/** The device-registry listing query. */
|
|
86
|
+
export const ListDevicesQuery = z
|
|
87
|
+
.object({
|
|
88
|
+
userId: z
|
|
89
|
+
.string()
|
|
90
|
+
.min(1)
|
|
91
|
+
.max(255)
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("Narrow to one user's devices. Absent walks the whole fleet, most-recently-seen first."),
|
|
94
|
+
platform: DevicePlatform.optional().describe("Narrow to one platform."),
|
|
95
|
+
cursor: PageCursorParam,
|
|
96
|
+
limit: PageLimit,
|
|
97
|
+
})
|
|
98
|
+
.describe("The device-registry query: what to filter by, how many to return, and where to resume.");
|
|
99
|
+
export type ListDevicesQuery = z.output<typeof ListDevicesQuery>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The body of `POST /admin/sessions/revoke`.
|
|
103
|
+
*
|
|
104
|
+
* A session **id**, never a session token. The token is the live credential — a management client that
|
|
105
|
+
* could name one would be holding the thing it is revoking, and a route that accepted one would be an
|
|
106
|
+
* oracle for whether a captured token is still valid. The id is the row's public handle, which is what
|
|
107
|
+
* the read routes project.
|
|
108
|
+
*/
|
|
109
|
+
export const RevokeSessionBody = z
|
|
110
|
+
.object({
|
|
111
|
+
sessionId: z
|
|
112
|
+
.string()
|
|
113
|
+
.min(1)
|
|
114
|
+
.max(255)
|
|
115
|
+
.describe("The session's id, as the admin read routes report it. Not the session token."),
|
|
116
|
+
})
|
|
117
|
+
.describe("The body identifying which single session to revoke.");
|
|
118
|
+
export type RevokeSessionBody = z.output<typeof RevokeSessionBody>;
|