@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,501 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The browser half of `@pithy-sh/auth`: the one place that knows how a browser program makes a
|
|
6
|
+
* same-origin, cookie-bearing request to this Worker, and the calls made through it — the six a
|
|
7
|
+
* scaffolded screen makes, and {@link updateUser}, which an adopter's own screen calls.
|
|
8
|
+
*
|
|
9
|
+
* **This module lives in the package for the reason `@pithy-sh/payments`'s counterpart does, only more
|
|
10
|
+
* so.** `pithy ui add` writes the auth screens once and may never rewrite them: they are copied into
|
|
11
|
+
* the adopter's repository and become the adopter's files. Every request written into one of those
|
|
12
|
+
* files is frozen there at the moment `pithy init` ran, and a later fix to the base path, the cookie
|
|
13
|
+
* mode or the failure directions reaches none of them. Six such requests existed — `/get-session`,
|
|
14
|
+
* `/sign-out`, the two OTP routes, the magic link, and the social handoff — each spelling out the same
|
|
15
|
+
* transport by hand. They call this now, and this upgrades with a minor release (#370).
|
|
16
|
+
*
|
|
17
|
+
* **Cookie/session, same origin, and that is enforced rather than assumed.** Every request carries
|
|
18
|
+
* `credentials: "include"` and nothing else: no token in `localStorage` or `sessionStorage`, no
|
|
19
|
+
* `Authorization` header, no refresh rotation. The SPA and its Worker share an origin, so the session
|
|
20
|
+
* rides an httpOnly cookie JavaScript cannot read. Bearer is the mobile path — the same routes serve
|
|
21
|
+
* it, documented rather than scaffolded.
|
|
22
|
+
*
|
|
23
|
+
* **CSRF travels with the cookie mode, and half of it is on this side of the wire.** The server's
|
|
24
|
+
* `requireSameOrigin()` refuses a cookie-authenticated mutating request whose `Origin` does not match;
|
|
25
|
+
* the browser only attaches that header to a request it considers this page's own. So the client half
|
|
26
|
+
* of the rule is that the request never leaves the origin — {@link callAuth} builds its URL from a base
|
|
27
|
+
* path and a relative path and **refuses anything that is not a rooted same-origin path**, rather than
|
|
28
|
+
* handing an ambient session to whatever a mis-set `basePath` names. A caller cannot forget the check,
|
|
29
|
+
* because a caller no longer writes the request.
|
|
30
|
+
*
|
|
31
|
+
* **Nothing here throws.** Not a stylistic choice: this module imports nothing, so `PithyError` is not
|
|
32
|
+
* in reach, and a bare `throw new Error` is exactly what this kit forbids. An unreachable Worker, a
|
|
33
|
+
* proxy's HTML error page, a 500 — each becomes a renderable {@link AuthFailure} on an
|
|
34
|
+
* {@link AuthResult}, and every caller answers one. The Worker is still the security boundary; nothing
|
|
35
|
+
* on this side of the wire protects anything, it only decides what a screen shows.
|
|
36
|
+
*
|
|
37
|
+
* **No absolute URL literals, and no schema library.** Paths are relative, so the calls follow whatever
|
|
38
|
+
* origin the bundle is served from. Answers are narrowed by hand-written `is…(value: unknown): value is
|
|
39
|
+
* T` guards rather than Zod: this file compiles into an adopter's browser bundle, and it must not drag
|
|
40
|
+
* the Worker's schema graph in behind it. `sameOrigin.test.ts` holds the import list to empty, so the
|
|
41
|
+
* property is structural rather than a habit.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** Where the auth handler mounts by default — the same default `AuthConfig.basePath` carries. */
|
|
45
|
+
export const AUTH_BASE_PATH = "/auth";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The slice of `fetch` this module uses, declared structurally.
|
|
49
|
+
*
|
|
50
|
+
* Not `typeof fetch`: the package compiles against `@cloudflare/workers-types`, whose `RequestInit` has
|
|
51
|
+
* no `credentials` — and `credentials: "include"` is the entire cookie story. Declaring the shape keeps
|
|
52
|
+
* one signature true in a Worker-typed program, in a browser, and in a test that injects a stub.
|
|
53
|
+
*/
|
|
54
|
+
export interface AuthRequestInit {
|
|
55
|
+
/** The HTTP method. Absent means GET. */
|
|
56
|
+
method?: string;
|
|
57
|
+
/** Request headers. */
|
|
58
|
+
headers?: Record<string, string>;
|
|
59
|
+
/** The JSON body, already serialized. */
|
|
60
|
+
body?: string;
|
|
61
|
+
/** Cookie policy. Always `include` here — same-origin, httpOnly session cookie. */
|
|
62
|
+
credentials?: "include" | "same-origin" | "omit";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The slice of `Response` this module reads. */
|
|
66
|
+
export interface AuthResponse {
|
|
67
|
+
/** Whether the status was 2xx. */
|
|
68
|
+
ok: boolean;
|
|
69
|
+
/** The HTTP status. */
|
|
70
|
+
status: number;
|
|
71
|
+
/** The parsed body, or a rejection when it was not JSON. */
|
|
72
|
+
json(): Promise<unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A fetch this module can call. */
|
|
76
|
+
export type AuthFetch = (input: string, init?: AuthRequestInit) => Promise<AuthResponse>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The browser globals this module reaches for, as an injectable seam.
|
|
80
|
+
*
|
|
81
|
+
* Reached through an object rather than `window` because the package compiles in a program with no DOM
|
|
82
|
+
* lib, alongside the Worker code. Injecting it is also what lets a test prove the no-browser path
|
|
83
|
+
* without a DOM.
|
|
84
|
+
*/
|
|
85
|
+
export interface AuthGlobal {
|
|
86
|
+
/** The browser's fetch. */
|
|
87
|
+
fetch?: AuthFetch;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What a humanity check contributes to a gated request: the body it wants sent, and any headers.
|
|
92
|
+
*
|
|
93
|
+
* Exactly the shape `@pithy-sh/turnstile`'s scaffolded helper already returns, so a screen hands its
|
|
94
|
+
* check straight in. Absent means no check is composed, which is the common case.
|
|
95
|
+
*/
|
|
96
|
+
export type AuthGate = (body: Record<string, unknown>) => {
|
|
97
|
+
/** The body to send, with whatever field the check wanted added. */
|
|
98
|
+
body: Record<string, unknown>;
|
|
99
|
+
/** The headers to send, with whatever header the check wanted added. */
|
|
100
|
+
headers: Record<string, string>;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** What every call takes: where the routes are, and the seams a test replaces. */
|
|
104
|
+
export interface AuthClientOptions {
|
|
105
|
+
/** Where the auth routes mount. Defaults to {@link AUTH_BASE_PATH}. */
|
|
106
|
+
basePath?: string;
|
|
107
|
+
/** The fetch to use. Defaults to the one on {@link AuthClientOptions.global}. */
|
|
108
|
+
fetch?: AuthFetch;
|
|
109
|
+
/** The global object the default fetch comes off. Defaults to `globalThis`. */
|
|
110
|
+
global?: AuthGlobal;
|
|
111
|
+
/** The humanity check to satisfy, on the two routes that are gated by one. */
|
|
112
|
+
gate?: AuthGate;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A refusal a screen can render: the namespaced code, the public message, and what to do next. */
|
|
116
|
+
export interface AuthFailure {
|
|
117
|
+
/** The namespaced code — `auth/invalid_token`, or a `client/*` sentinel this module minted. */
|
|
118
|
+
code: string;
|
|
119
|
+
/** The public message. The server's `detail` never crosses the HTTP codec, so this is all there is. */
|
|
120
|
+
message: string;
|
|
121
|
+
/** What to do next, when the server offered one. */
|
|
122
|
+
action: string | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Either the value, or a failure to render. Never a throw. */
|
|
126
|
+
export type AuthResult<T> = { ok: true; value: T } | { ok: false; failure: AuthFailure };
|
|
127
|
+
|
|
128
|
+
/** The worker could not be reached at all. Offline, or a DNS failure, or no fetch in this program. */
|
|
129
|
+
export const AUTH_UNREACHABLE: AuthFailure = {
|
|
130
|
+
code: "client/unreachable",
|
|
131
|
+
message: "We couldn't reach the server.",
|
|
132
|
+
action: "Check your connection, then try again.",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** The worker answered with something this client cannot read. A proxy's HTML page, or a shape change. */
|
|
136
|
+
export const AUTH_UNREADABLE: AuthFailure = {
|
|
137
|
+
code: "client/unreadable",
|
|
138
|
+
message: "The server answered with something we couldn't read.",
|
|
139
|
+
action: "Try again. If it keeps happening, the app and the backend are out of step.",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The request would have left this origin, so it was never sent.
|
|
144
|
+
*
|
|
145
|
+
* The only way to reach it is a `basePath` or a path that is not a rooted same-origin path — an
|
|
146
|
+
* absolute URL, a protocol-relative `//host`, a backslash form a URL parser reads as one. Sending it
|
|
147
|
+
* would attach an ambient session to a request this page is not entitled to make, which is the thing
|
|
148
|
+
* the cookie mode exists to keep from happening.
|
|
149
|
+
*/
|
|
150
|
+
export const AUTH_CROSS_ORIGIN: AuthFailure = {
|
|
151
|
+
code: "client/cross_origin",
|
|
152
|
+
message: "That request would have left this site, so it wasn't sent.",
|
|
153
|
+
action: "Check the auth `basePath` in pithy.config.ts — it is a path on this worker, not a URL.",
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Whether a value is a plain record — the first step of every guard below. */
|
|
157
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
158
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether a composed path stays on this origin.
|
|
163
|
+
*
|
|
164
|
+
* A rooted path and nothing that resolves elsewhere. `//host` and `/\host` are the two forms a URL
|
|
165
|
+
* parser reads as an authority rather than as a path, and a browser follows both — which is why the
|
|
166
|
+
* check is on the second character rather than on a scheme it would never find after a leading `/`.
|
|
167
|
+
*/
|
|
168
|
+
function isSameOriginPath(path: string): boolean {
|
|
169
|
+
if (!path.startsWith("/")) return false;
|
|
170
|
+
const second = path.charAt(1);
|
|
171
|
+
return second !== "/" && second !== "\\";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A failure read off either shape this Worker answers with, or the generic one when the body is neither.
|
|
176
|
+
*
|
|
177
|
+
* **Two shapes, because two packages own routes here and only one of them is ours.** Everything this
|
|
178
|
+
* capability writes answers `{ error: { code, message, action } }` through `pithyErrorHandler`. Every
|
|
179
|
+
* route Better Auth owns — the one-time code, the magic link, sign-out, `get-session`, the social
|
|
180
|
+
* handoff, `update-user` — answers its own flat `{ message, code }`, because better-call renders an
|
|
181
|
+
* endpoint's `APIError` into a Response inside `instance.handler` and nothing on our side of that ever
|
|
182
|
+
* sees it (#449).
|
|
183
|
+
*
|
|
184
|
+
* Reading only the envelope meant every one of those refusals arrived as {@link AUTH_UNREADABLE}, so a
|
|
185
|
+
* reader who mistyped their one-time code was told the app was broken rather than that the code was
|
|
186
|
+
* wrong — in a screen `pithy ui add` copies into an adopter's repository, where it can never be fixed.
|
|
187
|
+
*
|
|
188
|
+
* **The flat shape is read rather than rewritten on the server, deliberately.** `packages/auth/README.md`
|
|
189
|
+
* documents `createAuthClient` from `better-auth/client` as a first-class client surface (#271), and
|
|
190
|
+
* `@better-fetch/fetch` builds its error as `{ ...parsedBody, status }` — so an adopter on that path
|
|
191
|
+
* reads `error.code === "INVALID_OTP"` today. Re-homing the body into our envelope would make that
|
|
192
|
+
* `undefined` for every one of them. The wire is Better Auth's contract; this side learns to read it.
|
|
193
|
+
*
|
|
194
|
+
* So `code` arrives in whichever vocabulary produced it, and the two are told apart by their shape:
|
|
195
|
+
* `auth/invalid_token` is ours, `INVALID_OTP` is Better Auth's. A screen matching on a code should
|
|
196
|
+
* expect the one belonging to the route it called.
|
|
197
|
+
*/
|
|
198
|
+
function readFailure(body: unknown): AuthFailure {
|
|
199
|
+
if (!isRecord(body)) return AUTH_UNREADABLE;
|
|
200
|
+
|
|
201
|
+
// The kit's envelope, which every route this capability writes answers with.
|
|
202
|
+
if (isRecord(body.error)) {
|
|
203
|
+
const { code, message, action } = body.error;
|
|
204
|
+
if (typeof code !== "string" || typeof message !== "string") return AUTH_UNREADABLE;
|
|
205
|
+
return { code, message, action: typeof action === "string" ? action : null };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Better Auth's own, which every route it owns answers with.
|
|
209
|
+
const { code, message } = body;
|
|
210
|
+
if (typeof code !== "string" || typeof message !== "string") return AUTH_UNREADABLE;
|
|
211
|
+
return { code, message, action: null };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* One call: same-origin, cookie-carrying, never throwing. **The only producer of that request.**
|
|
216
|
+
*
|
|
217
|
+
* The body is read before the status is judged, because a refusal's body is the failure a screen
|
|
218
|
+
* renders. A body that will not parse is the generic failure rather than a crash — a corporate proxy's
|
|
219
|
+
* HTML page reaches a browser far more often than anyone expects.
|
|
220
|
+
*
|
|
221
|
+
* **Exported, and that is the point of it.** `@pithy-sh/auth` shipped no browser primitive at all, so
|
|
222
|
+
* six scaffolded screens each wrote the transport out by hand — and those screens are copied into an
|
|
223
|
+
* adopter's repository, where Pithy can never fix them. Every browser program that asks this Worker an
|
|
224
|
+
* auth question calls this, and `sameOrigin.test.ts` fails the build on any module that grows its own
|
|
225
|
+
* (#370).
|
|
226
|
+
*
|
|
227
|
+
* **Zod-free, like everything on this side of the wire.** The answer is narrowed by the hand-written
|
|
228
|
+
* `guard` a caller passes, never by a schema: this compiles into an adopter's browser bundle, and
|
|
229
|
+
* dragging the Worker's schema graph in behind it would break their build rather than ours.
|
|
230
|
+
*/
|
|
231
|
+
export async function callAuth<T>(
|
|
232
|
+
path: string,
|
|
233
|
+
init: AuthRequestInit,
|
|
234
|
+
options: AuthClientOptions | undefined,
|
|
235
|
+
guard: (value: unknown) => value is T,
|
|
236
|
+
): Promise<AuthResult<T>> {
|
|
237
|
+
const base = options?.basePath ?? AUTH_BASE_PATH;
|
|
238
|
+
const url = `${base}${path}`;
|
|
239
|
+
// Before anything else, and before any fetch: the cookie mode below is only safe because this is a
|
|
240
|
+
// request to this Worker. `base` is adopter config; `path` is ours.
|
|
241
|
+
if (!isSameOriginPath(url)) return { ok: false, failure: AUTH_CROSS_ORIGIN };
|
|
242
|
+
|
|
243
|
+
const fetcher = options?.fetch ?? (options?.global ?? (globalThis as AuthGlobal)).fetch;
|
|
244
|
+
if (!fetcher) return { ok: false, failure: AUTH_UNREACHABLE };
|
|
245
|
+
|
|
246
|
+
let response: AuthResponse;
|
|
247
|
+
try {
|
|
248
|
+
response = await fetcher(url, { ...init, credentials: "include" });
|
|
249
|
+
} catch {
|
|
250
|
+
return { ok: false, failure: AUTH_UNREACHABLE };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let body: unknown;
|
|
254
|
+
try {
|
|
255
|
+
body = await response.json();
|
|
256
|
+
} catch {
|
|
257
|
+
// Success or refusal, an unparseable body says the same thing: whatever answered was not this Worker.
|
|
258
|
+
return { ok: false, failure: AUTH_UNREADABLE };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!response.ok) return { ok: false, failure: readFailure(body) };
|
|
262
|
+
return guard(body) ? { ok: true, value: body } : { ok: false, failure: AUTH_UNREADABLE };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A JSON POST, with the humanity check's contribution already folded in.
|
|
267
|
+
*
|
|
268
|
+
* The gate is applied here rather than at each call site for the reason the cookie mode is: a screen
|
|
269
|
+
* that forgets it sends an ungated request to a gated route and gets a 403 it cannot explain.
|
|
270
|
+
*/
|
|
271
|
+
function jsonPost(body: Record<string, unknown>, options: AuthClientOptions | undefined): AuthRequestInit {
|
|
272
|
+
const gated = options?.gate ? options.gate(body) : { body, headers: {} };
|
|
273
|
+
return {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: { "content-type": "application/json", ...gated.headers },
|
|
276
|
+
body: JSON.stringify(gated.body),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The signed-in user, as far as a browser is concerned. */
|
|
281
|
+
export interface AuthUser {
|
|
282
|
+
/** The user's id. */
|
|
283
|
+
id: string;
|
|
284
|
+
/** Their email address. */
|
|
285
|
+
email: string;
|
|
286
|
+
/** Their display name, when they have one. */
|
|
287
|
+
name?: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A live session, or `null` when nobody is signed in. */
|
|
291
|
+
export type AuthSession = { user: AuthUser } | null;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Whether a value is a session envelope, or the explicit "nobody" the route answers with.
|
|
295
|
+
*
|
|
296
|
+
* `null` is a valid answer and a distinct one from a failure — signed out is not the same as unread —
|
|
297
|
+
* so it is narrowed here rather than collapsed into one by a caller.
|
|
298
|
+
*/
|
|
299
|
+
function isSession(value: unknown): value is AuthSession {
|
|
300
|
+
if (value === null) return true;
|
|
301
|
+
if (!isRecord(value)) return false;
|
|
302
|
+
const user = value.user;
|
|
303
|
+
return isRecord(user) && typeof user.id === "string" && typeof user.email === "string";
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Whether a value is any JSON object. The floor for a route whose success body a screen does not read. */
|
|
307
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
308
|
+
return isRecord(value);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Who is signed in, or `null`.
|
|
313
|
+
*
|
|
314
|
+
* **A failure is not a signed-out session**, and the return type is what keeps the two apart. A screen
|
|
315
|
+
* that renders "signed out" from an unreachable Worker offers a sign-in form to somebody who is
|
|
316
|
+
* already signed in, and the sign-in it starts replaces a live session with a new one. A caller that
|
|
317
|
+
* wants to treat them alike writes `result.ok ? result.value : null` — one line, reading as the
|
|
318
|
+
* decision it is.
|
|
319
|
+
*/
|
|
320
|
+
export function getSession(options?: AuthClientOptions): Promise<AuthResult<AuthSession>> {
|
|
321
|
+
return callAuth("/get-session", {}, options, isSession);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* End the session server-side.
|
|
326
|
+
*
|
|
327
|
+
* **The content type is not decoration, and the scaffolded screen was missing it.** The route refuses a
|
|
328
|
+
* POST without `application/json` with a 415 — so every app scaffolded so far has been signing nobody
|
|
329
|
+
* out. It went unnoticed because the screen ignored the answer and navigated to `/sign-in` regardless,
|
|
330
|
+
* which looks exactly like a successful sign-out until the back button restores a live session.
|
|
331
|
+
* `clientRoundTrip.test.ts` is what found it, and is what would find it again: a request written into a
|
|
332
|
+
* file Pithy may never rewrite is a request nobody ever tested against the route it names (#370).
|
|
333
|
+
*
|
|
334
|
+
* The body is an empty object rather than nothing, because a declared JSON content type with no body is
|
|
335
|
+
* the other half of the same 415.
|
|
336
|
+
*/
|
|
337
|
+
export function signOut(options?: AuthClientOptions): Promise<AuthResult<Record<string, unknown>>> {
|
|
338
|
+
// Not `jsonPost`: that folds in the humanity check, and sign-out is not a gated route.
|
|
339
|
+
return callAuth(
|
|
340
|
+
"/sign-out",
|
|
341
|
+
{ method: "POST", headers: { "content-type": "application/json" }, body: "{}" },
|
|
342
|
+
options,
|
|
343
|
+
isObject,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Store something on the signed-in reader's own account. Today that is their language, and only that.
|
|
349
|
+
*
|
|
350
|
+
* **The rule it exists to serve is `docs/I18N.md`'s: `pithy_auth_users.locale` is the one home for a
|
|
351
|
+
* person's language — do not put language in your own preferences table.** A preferences table is the
|
|
352
|
+
* right home for time zone, date format, a 24-hour clock; none of those is language. Two homes for one
|
|
353
|
+
* fact is not a duplicate row, it is a magic-link email in the wrong language with nothing anywhere
|
|
354
|
+
* failing to say so, because the send Workflow reads the auth column and the settings pane reads the
|
|
355
|
+
* other one. This is the write that keeps the home single, and `useNegotiatedLocale`'s `persist` seam
|
|
356
|
+
* (`@pithy-sh/i18n`) is the caller it was written for: `persist: (next) => { void updateUser({ locale: next }); }`.
|
|
357
|
+
* The discard is deliberate: `persist` returns `void | Promise<void>` and this resolves to an
|
|
358
|
+
* `AuthResult`, so returning the call straight does not typecheck — and nothing is lost by dropping
|
|
359
|
+
* it, because nothing here throws.
|
|
360
|
+
*
|
|
361
|
+
* **`locale` is the only field it takes, because it is the only kit user field declared `input: true`**
|
|
362
|
+
* (`../data/kitFields`). Every other column Pithy adds is server-set — a client that could name its own
|
|
363
|
+
* device or token family could name somebody else's — while a language is the reader's own preference
|
|
364
|
+
* and no admin route is a thing a reader has. What makes taking it from a client safe is the `Locale`
|
|
365
|
+
* validator on the field rather than this signature: Better Auth runs it before the write, so the same
|
|
366
|
+
* schema that guards every read guards the write one hop earlier, and a megabyte of junk is a 400 here
|
|
367
|
+
* instead of a row that throws for every operator who later lists users.
|
|
368
|
+
*
|
|
369
|
+
* **`null` is accepted deliberately, and it is not the empty string.** Null means *this reader has not
|
|
370
|
+
* chosen*, which is what makes the server fall back to `Accept-Language`; taking a language choice back
|
|
371
|
+
* is an ordinary thing a reader does, and a call that refused it would answer 400 for the one state the
|
|
372
|
+
* schema calls ordinary.
|
|
373
|
+
*
|
|
374
|
+
* **Not `jsonPost`, for the reason {@link signOut} is not.** That folds in the humanity check, and this
|
|
375
|
+
* is not a gated route: `createAuthRoutes` stacks the Turnstile gate on `/sign-in/magic-link` and
|
|
376
|
+
* `/email-otp/send-verification-otp` and on nothing else, so a token folded in here would be an
|
|
377
|
+
* unexpected field on a request Better Auth validates itself. The content type and the `{}` body are
|
|
378
|
+
* the same 415 story as sign-out — a declared JSON type with no body is refused just as an undeclared
|
|
379
|
+
* one is.
|
|
380
|
+
*
|
|
381
|
+
* **And it is kit code rather than an adopter's, because both alternatives are worse than this
|
|
382
|
+
* function.** Standing up a second Better Auth client with `inferAdditionalFields` duplicates
|
|
383
|
+
* configuration `@pithy-sh/auth` already owns. Reaching for {@link callAuth} instead means an adopter
|
|
384
|
+
* spelling out the path, the content type and the guard themselves, one deep import below the surface
|
|
385
|
+
* every other auth call in their app goes through — written into a file Pithy can never fix (#446).
|
|
386
|
+
*
|
|
387
|
+
* **The answer carries no user, whatever the route's own OpenAPI block advertises.** Better Auth's
|
|
388
|
+
* `/update-user` ends `ctx.json({ status: true })`, so `value` is `{ status: true }` and
|
|
389
|
+
* `value.locale` is `undefined`. Nothing is lost by that — a screen already has the value it just
|
|
390
|
+
* wrote — but do not reach into it for the row.
|
|
391
|
+
*
|
|
392
|
+
* **A refusal is a failure to render and never a throw, and it is `client/unreadable` more often than
|
|
393
|
+
* it looks.** This is Better Auth's own route, and Better Auth turns its own `APIError`s into
|
|
394
|
+
* Responses inside `instance.handler`, so they never reach the `apiErrorToPithy` re-homing in
|
|
395
|
+
* `../http/routes`. The body stays Better Auth's flat `{ message, code }` rather than the kit's error
|
|
396
|
+
* envelope, and {@link AUTH_UNREADABLE} is what `readFailure` makes of anything that is not that
|
|
397
|
+
* envelope: a signed-out reader's 401 reads as `client/unreadable`, and so does a tag the `Locale`
|
|
398
|
+
* validator rejected. Which is enough for the caller this exists for — `useNegotiatedLocale` drops a
|
|
399
|
+
* failed preference write either way, because the reader already has the language they picked.
|
|
400
|
+
*/
|
|
401
|
+
export function updateUser(
|
|
402
|
+
fields: { locale?: string | null },
|
|
403
|
+
options?: AuthClientOptions,
|
|
404
|
+
): Promise<AuthResult<Record<string, unknown>>> {
|
|
405
|
+
return callAuth(
|
|
406
|
+
"/update-user",
|
|
407
|
+
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...fields }) },
|
|
408
|
+
options,
|
|
409
|
+
isObject,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Sign in with a one-time code the user typed. The answer is the session it created. */
|
|
414
|
+
export function signInWithOtp(
|
|
415
|
+
input: { email: string; otp: string },
|
|
416
|
+
options?: AuthClientOptions,
|
|
417
|
+
): Promise<AuthResult<Record<string, unknown>>> {
|
|
418
|
+
return callAuth("/sign-in/email-otp", jsonPost({ ...input }, options), options, isObject);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Mail a one-time code.
|
|
423
|
+
*
|
|
424
|
+
* Gated by the humanity check when one is composed — `options.gate` puts the token where the middleware
|
|
425
|
+
* reads it, and a screen cannot forget to.
|
|
426
|
+
*/
|
|
427
|
+
export function sendOtp(
|
|
428
|
+
input: { email: string; type: "sign-in" },
|
|
429
|
+
options?: AuthClientOptions,
|
|
430
|
+
): Promise<AuthResult<Record<string, unknown>>> {
|
|
431
|
+
return callAuth("/email-otp/send-verification-otp", jsonPost({ ...input }, options), options, isObject);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Mail a magic link.
|
|
436
|
+
*
|
|
437
|
+
* `callbackURL` is the adopter's own screen, so it is theirs to name. Gated the same way as
|
|
438
|
+
* {@link sendOtp}.
|
|
439
|
+
*/
|
|
440
|
+
export function sendMagicLink(
|
|
441
|
+
input: { email: string; callbackURL: string },
|
|
442
|
+
options?: AuthClientOptions,
|
|
443
|
+
): Promise<AuthResult<Record<string, unknown>>> {
|
|
444
|
+
return callAuth("/sign-in/magic-link", jsonPost({ ...input }, options), options, isObject);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* What asking for a social authorization URL produced. Three outcomes, and a screen must tell them apart.
|
|
449
|
+
*
|
|
450
|
+
* `authorize` is a URL to leave for. `unconfigured` is the provider being switched on in config with a
|
|
451
|
+
* blank credential behind it — the server answers, and the URL it mints names no client. `refused` is
|
|
452
|
+
* our own Worker not answering. Different faults, different copy, and collapsing them would tell a user
|
|
453
|
+
* to try again when the fix is a secret nobody set.
|
|
454
|
+
*/
|
|
455
|
+
export type SocialSignIn =
|
|
456
|
+
| { kind: "authorize"; url: string }
|
|
457
|
+
| { kind: "unconfigured" }
|
|
458
|
+
| { kind: "refused"; failure: AuthFailure };
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Whether an answer is an authorization URL a browser may be sent to, and the two reasons it might not be.
|
|
462
|
+
*
|
|
463
|
+
* **The scheme check.** `window.location.href = url` with a `javascript:` URL executes that script in
|
|
464
|
+
* this page, so a response body is not something to hand straight to a navigator however trusted its
|
|
465
|
+
* origin.
|
|
466
|
+
*
|
|
467
|
+
* **The `client_id` check.** An OAuth 2.0 authorization request must carry one, so an authorization URL
|
|
468
|
+
* naming no client is a provider with a blank credential behind it. The client projection carries
|
|
469
|
+
* booleans and never credentials, so a browser cannot know that in advance — refusing the *response* is
|
|
470
|
+
* what turns a blank credential into a sentence on the screen rather than a bounce to Google's own
|
|
471
|
+
* error page.
|
|
472
|
+
*/
|
|
473
|
+
function isAuthorization(value: unknown): value is { url: string } {
|
|
474
|
+
if (!isRecord(value) || typeof value.url !== "string") return false;
|
|
475
|
+
try {
|
|
476
|
+
const parsed = new URL(value.url);
|
|
477
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
|
478
|
+
return (parsed.searchParams.get("client_id") ?? "") !== "";
|
|
479
|
+
} catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Ask for a provider's authorization URL.
|
|
486
|
+
*
|
|
487
|
+
* **No humanity check, deliberately**: the redirect carries no token to check and the provider runs its
|
|
488
|
+
* own bot defense, which is what `@pithy-sh/auth` already assumes when it stacks the check on the
|
|
489
|
+
* magic-link route alone. Gating it would only stop people signing in.
|
|
490
|
+
*/
|
|
491
|
+
export async function startSocialSignIn(
|
|
492
|
+
input: { provider: string; callbackURL: string },
|
|
493
|
+
options?: AuthClientOptions,
|
|
494
|
+
): Promise<SocialSignIn> {
|
|
495
|
+
// The gate is dropped rather than passed through, so a screen holding one client options object for
|
|
496
|
+
// the whole page cannot accidentally gate this route.
|
|
497
|
+
const ungated: AuthClientOptions | undefined = options && { ...options, gate: undefined };
|
|
498
|
+
const result = await callAuth("/sign-in/social", jsonPost({ ...input }, undefined), ungated, isObject);
|
|
499
|
+
if (!result.ok) return { kind: "refused", failure: result.failure };
|
|
500
|
+
return isAuthorization(result.value) ? { kind: "authorize", url: result.value.url } : { kind: "unconfigured" };
|
|
501
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a browser may know about this project's auth — the shape of `virtual:pithy/auth`.
|
|
6
|
+
*
|
|
7
|
+
* **This declaration is the contract, and the projection is checked against it.** It is written here
|
|
8
|
+
* rather than inferred from the closure that builds it, and that is the whole point: an inferred type
|
|
9
|
+
* follows whatever the producer last happened to say, so a projection that dropped `otpLength`, or that
|
|
10
|
+
* started projecting `baseURL` because one screen wanted it, would take the type with it and nothing
|
|
11
|
+
* would go red. Declared, both are a compile error at the `client:` that wrote them, and widening what
|
|
12
|
+
* every adopter's bundle carries becomes a decision made here, on purpose.
|
|
13
|
+
*
|
|
14
|
+
* The `enabled: false` branch is not this capability's to produce. `resolveClientProjection` answers it
|
|
15
|
+
* for a capability nobody composed, and a front end is written against the capabilities it *may* have.
|
|
16
|
+
* It is declared here because it is what a browser reads, and the browser cannot tell the two apart.
|
|
17
|
+
*
|
|
18
|
+
* **This is the only statement of the shape.** `@pithy-sh/ui-react`'s `templates/client-env.d.ts` — the
|
|
19
|
+
* ambient declaration `pithy ui add react` copies into an adopter's Worker — is generated from this type
|
|
20
|
+
* by `@pithy-sh/vite`'s `clientEnvDeclaration.ts` (#398). The unions and the per-field doc comments below
|
|
21
|
+
* are emitted verbatim, so what is written here is what a screen author reads.
|
|
22
|
+
*/
|
|
23
|
+
export type AuthClientProjection =
|
|
24
|
+
| {
|
|
25
|
+
/** Auth is not composed on this worker. A screen branches rather than rendering a sign-in form. */
|
|
26
|
+
enabled: false;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
/** Auth is composed on this worker. */
|
|
30
|
+
enabled: true;
|
|
31
|
+
/** The path the auth handler mounts under, e.g. `/auth`. */
|
|
32
|
+
basePath: string;
|
|
33
|
+
/**
|
|
34
|
+
* Which social providers are switched on in pithy.config.ts. Credentials never reach the client —
|
|
35
|
+
* they are not in the config this projection can see, they are in the secrets store.
|
|
36
|
+
*
|
|
37
|
+
* Nested, so a screen iterates the set rather than naming four booleans. A fifth provider is one
|
|
38
|
+
* key here — and until it is written here, projecting it is a compile error, not a surprise in
|
|
39
|
+
* somebody's bundle.
|
|
40
|
+
*/
|
|
41
|
+
providers: {
|
|
42
|
+
/** Whether Sign in with Google is offered. */
|
|
43
|
+
google: boolean;
|
|
44
|
+
/** Whether Sign in with Apple is offered. */
|
|
45
|
+
apple: boolean;
|
|
46
|
+
/** Whether Sign in with Facebook is offered. */
|
|
47
|
+
facebook: boolean;
|
|
48
|
+
/** Whether Sign in with GitHub is offered. */
|
|
49
|
+
github: boolean;
|
|
50
|
+
};
|
|
51
|
+
/** How many digits an email OTP carries. */
|
|
52
|
+
otpLength: number;
|
|
53
|
+
/** Whether signing in may provision a new user. Drives the sign-up copy. */
|
|
54
|
+
signUpEnabled: boolean;
|
|
55
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/// <reference types="@cloudflare/vitest-plugin/types" />
|
|
5
|
+
|
|
6
|
+
// Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare
|
|
7
|
+
// config in `vitest.workers.config.ts`: the app `DB` database auth's tables live in, and the dedicated
|
|
8
|
+
// `SECRETS` database plus master key every auth secret is read through.
|
|
9
|
+
declare namespace Cloudflare {
|
|
10
|
+
interface Env {
|
|
11
|
+
DB: D1Database;
|
|
12
|
+
SECRETS: D1Database;
|
|
13
|
+
/** The master-key config as a string (the `.dev.vars` shape), set in `vitest.workers.config.ts`. */
|
|
14
|
+
SECRETS_ENCRYPTION_KEYS: string;
|
|
15
|
+
}
|
|
16
|
+
}
|