@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,93 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Session, User } from "../data/betterAuth";
|
|
5
|
+
import type { Device } from "../data/device";
|
|
6
|
+
import type { AdminDeviceView, AdminSessionView, AdminUserView } from "./responses";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The admin projections — what a management client is shown of a user, a session, and a device.
|
|
10
|
+
*
|
|
11
|
+
* **No handler returns a row.** Each function below states what leaves the Worker, and each return
|
|
12
|
+
* type is `z.output` of the matching object in `responses.ts`, so the shape a client validates
|
|
13
|
+
* against and the shape this produces are one declaration. Adding a field to one and not the other
|
|
14
|
+
* does not compile.
|
|
15
|
+
*
|
|
16
|
+
* Separated from `adminRoutes.ts` so they can be exercised without a request, a scope, or a
|
|
17
|
+
* credential: `responses.test.ts` runs each against a fully populated row and compares the result
|
|
18
|
+
* with its schema. A leak here is a leak in every pane, and it should not need a Workers pool to
|
|
19
|
+
* catch.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The user projection.
|
|
24
|
+
*
|
|
25
|
+
* Email and display name are personal data and they are here on purpose: identifying the right person
|
|
26
|
+
* is the entire job of a support pane, and `auth:users:read` is precisely the grant an adopter makes
|
|
27
|
+
* when they accept that. Nothing else on `pithy_auth_users` is withheld because nothing else on it is
|
|
28
|
+
* sensitive — the table holds no credential at all, which is what passwordless-only buys.
|
|
29
|
+
*
|
|
30
|
+
* `locale` is projected for the same reason: a support pane answering "why is this person getting
|
|
31
|
+
* English emails" needs to see whether they ever chose, and null — never chosen — is the answer half
|
|
32
|
+
* the time. It is a preference, not a credential.
|
|
33
|
+
*/
|
|
34
|
+
export function userView(user: User): AdminUserView {
|
|
35
|
+
return {
|
|
36
|
+
id: user.id,
|
|
37
|
+
email: user.email,
|
|
38
|
+
name: user.name,
|
|
39
|
+
emailVerified: user.emailVerified,
|
|
40
|
+
image: user.image,
|
|
41
|
+
locale: user.locale,
|
|
42
|
+
createdAt: user.createdAt.toISOString(),
|
|
43
|
+
updatedAt: user.updatedAt.toISOString(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The session projection — **without the token**.
|
|
49
|
+
*
|
|
50
|
+
* The token *is* the credential: a bearer of it is the user, everywhere, until it expires. Projecting
|
|
51
|
+
* it would turn a read scope into silent impersonation and would leave no trace distinguishable from
|
|
52
|
+
* the person's own activity, which is exactly the capability this surface refuses to offer. The `id` is
|
|
53
|
+
* the handle instead, and it is what `POST /admin/sessions/revoke` accepts.
|
|
54
|
+
*
|
|
55
|
+
* `familyId` is dropped too — it is internal rotation bookkeeping, and a pane that rendered it would
|
|
56
|
+
* invite somebody to act on a correlation the model does not promise to keep stable.
|
|
57
|
+
*
|
|
58
|
+
* The IP and user-agent stay: "where is this person signed in from" is the question the pane exists to
|
|
59
|
+
* answer, and it is the one that catches a stolen session.
|
|
60
|
+
*/
|
|
61
|
+
export function sessionView(session: Session): AdminSessionView {
|
|
62
|
+
return {
|
|
63
|
+
id: session.id,
|
|
64
|
+
deviceId: session.deviceId,
|
|
65
|
+
ipAddress: session.ipAddress,
|
|
66
|
+
userAgent: session.userAgent,
|
|
67
|
+
createdAt: session.createdAt.toISOString(),
|
|
68
|
+
updatedAt: session.updatedAt.toISOString(),
|
|
69
|
+
expiresAt: session.expiresAt.toISOString(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The device projection — **without the push token**.
|
|
75
|
+
*
|
|
76
|
+
* An APNs/FCM token is the capability to put a notification on somebody's lock screen. It is a
|
|
77
|
+
* credential, it is useless to a dashboard, and a management client that held one could message an
|
|
78
|
+
* adopter's users under the adopter's own app identity. It never leaves the Worker.
|
|
79
|
+
*/
|
|
80
|
+
export function deviceView(device: Device): AdminDeviceView {
|
|
81
|
+
return {
|
|
82
|
+
id: device.id,
|
|
83
|
+
userId: device.userId,
|
|
84
|
+
platform: device.platform,
|
|
85
|
+
name: device.name,
|
|
86
|
+
model: device.model,
|
|
87
|
+
osVersion: device.osVersion,
|
|
88
|
+
appVersion: device.appVersion,
|
|
89
|
+
lastIp: device.lastIp,
|
|
90
|
+
lastSeenAt: device.lastSeenAt.toISOString(),
|
|
91
|
+
createdAt: device.createdAt.toISOString(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//
|
|
4
|
+
// LOCALE es — an unreviewed first pass. Not American English by design.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What this kit says in Spanish that `@better-auth/i18n` does not (#452).
|
|
8
|
+
*
|
|
9
|
+
* **Only the gaps, and only the ones a reader meets.** The plugin ships 22 languages of its own, and
|
|
10
|
+
* they are maintained upstream — copying them here would fork every one of them on the day it landed.
|
|
11
|
+
* This layers over its `es`, which covers 34 of the 52 codes the kit's composed plugin set can raise.
|
|
12
|
+
*
|
|
13
|
+
* The three that matter most were among the missing: `INVALID_OTP`, `OTP_EXPIRED` and
|
|
14
|
+
* `TOO_MANY_ATTEMPTS` are the whole of `emailOTP`'s vocabulary, which is to say the whole of what a
|
|
15
|
+
* person meets when a passwordless sign-in goes wrong. A reader who mistyped a code was reading English
|
|
16
|
+
* on an otherwise Spanish screen.
|
|
17
|
+
*
|
|
18
|
+
* The rest of the gap is misconfiguration an adopter causes rather than anything a reader can act on,
|
|
19
|
+
* and it stays English deliberately — see `ENGLISH_ON_PURPOSE` in `./errorCopy`.
|
|
20
|
+
*/
|
|
21
|
+
export const AUTH_ERRORS_ES = {
|
|
22
|
+
/** A one-time code that is not the one. The commonest refusal there is. */
|
|
23
|
+
INVALID_OTP: "El código no es válido.",
|
|
24
|
+
/** The code was right once. Codes are short-lived on purpose. */
|
|
25
|
+
OTP_EXPIRED: "El código ha caducado. Pide uno nuevo.",
|
|
26
|
+
/** Rate limiting on the verification attempt, not on the request for a code. */
|
|
27
|
+
TOO_MANY_ATTEMPTS: "Demasiados intentos. Espera un momento y vuelve a intentarlo.",
|
|
28
|
+
/**
|
|
29
|
+
* The sign-in was reached by a cross-site navigation and refused.
|
|
30
|
+
*
|
|
31
|
+
* A person can meet this without doing anything wrong — an embedded browser, a link opened from
|
|
32
|
+
* another site — so it says what to do rather than naming the mechanism.
|
|
33
|
+
*/
|
|
34
|
+
CROSS_SITE_NAVIGATION_LOGIN_BLOCKED: "Por seguridad, abre el enlace directamente en tu navegador.",
|
|
35
|
+
} as const satisfies Record<string, string>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { locales } from "@better-auth/i18n";
|
|
5
|
+
import type { BetterAuthPlugin } from "better-auth";
|
|
6
|
+
import { BASE_ERROR_CODES } from "better-auth";
|
|
7
|
+
import { AUTH_ERRORS_ES } from "./errorCopy.es";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The words Better Auth's own refusals are said in.
|
|
11
|
+
*
|
|
12
|
+
* ## Why this is server-side, when every other translation in the kit is not
|
|
13
|
+
*
|
|
14
|
+
* `docs/I18N.md` holds that the server never localizes an error: a `PithyError`'s `message` stays
|
|
15
|
+
* English permanently, because it is at once the operator's diagnostic and the fallback for a client
|
|
16
|
+
* that cannot translate, and a translating client renders `t.maybe(code, params) ?? message`.
|
|
17
|
+
*
|
|
18
|
+
* These are not `PithyError`s. Better Auth owns these routes and answers them in its own flat
|
|
19
|
+
* `{ message, code }` before anything of ours sees the failure — better-call renders an endpoint's
|
|
20
|
+
* `APIError` into a Response inside `instance.handler` (#449). So the choice is not *where* to
|
|
21
|
+
* translate them but *whether*, and leaving them was leaving the last English sentences on an otherwise
|
|
22
|
+
* translated screen — met by the most ordinary mistake there is, mistyping a one-time code.
|
|
23
|
+
*
|
|
24
|
+
* Translating them at the server also reaches a caller the client seam cannot: a mobile app holding a
|
|
25
|
+
* bearer token, or a second-party integration, gets the reader's language without shipping
|
|
26
|
+
* `@pithy-sh/i18n`. And nothing is lost to the operator, because the plugin keeps the English on
|
|
27
|
+
* `originalMessage` rather than replacing it.
|
|
28
|
+
*
|
|
29
|
+
* ## Where the words come from
|
|
30
|
+
*
|
|
31
|
+
* `@better-auth/i18n` (MIT) ships 22 languages of its own, maintained upstream. This layers over them
|
|
32
|
+
* rather than restating them: copying a locale here would fork it on the day it landed, and the kit's
|
|
33
|
+
* own rule for catalogs is that they ship in the package and are never copied. {@link AUTH_ERRORS_ES}
|
|
34
|
+
* carries only what its `es` is missing.
|
|
35
|
+
*
|
|
36
|
+
* **The layering is per key, never per locale**, for the same reason `composeMessages` merges that way:
|
|
37
|
+
* a locale object replacing another is a fork wearing the shape of an override.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Every locale this kit writes Better Auth's refusals in. English is the source and is not listed. */
|
|
41
|
+
const KIT_OVERRIDES: Record<string, Record<string, string>> = { es: AUTH_ERRORS_ES };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Codes that stay English on purpose, and why.
|
|
45
|
+
*
|
|
46
|
+
* **A declared list rather than a silent remainder.** The gate in `./errorCopy.test.ts` requires every
|
|
47
|
+
* code the composed plugin set can raise to be either translated or named here, so a code Better Auth
|
|
48
|
+
* adds in a later release fails the build instead of quietly reaching somebody in English. That is the
|
|
49
|
+
* same shape the migration-order table uses, and for the same reason: the property is only true as a
|
|
50
|
+
* set.
|
|
51
|
+
*
|
|
52
|
+
* Every entry is a fault an **adopter** caused in their own configuration or their own request, not
|
|
53
|
+
* anything a reader can act on. Translating those makes them harder to search for and no easier to fix.
|
|
54
|
+
*/
|
|
55
|
+
export const ENGLISH_ON_PURPOSE: Record<string, string> = {
|
|
56
|
+
ASYNC_VALIDATION_NOT_SUPPORTED: "A field validator returned a promise where the adapter takes none.",
|
|
57
|
+
BODY_MUST_BE_AN_OBJECT: "The caller sent something that is not a JSON object.",
|
|
58
|
+
CALLBACK_URL_REQUIRED: "The adopter's own call omitted the URL it wants a reader returned to.",
|
|
59
|
+
CHANGE_EMAIL_DISABLED: "The adopter switched the feature off in their own config.",
|
|
60
|
+
FAILED_TO_CREATE_VERIFICATION: "A write this Worker owns failed; the operator reads our logs, not a reader.",
|
|
61
|
+
FIELD_NOT_ALLOWED: "The caller sent a field the adopter's own schema does not declare.",
|
|
62
|
+
ID_TOKEN_NOT_SUPPORTED: "The configured provider does not do id-token sign-in.",
|
|
63
|
+
INVALID_CALLBACK_URL: "The adopter's configured callback is not a URL this Worker trusts.",
|
|
64
|
+
INVALID_ERROR_CALLBACK_URL: "As above, for the error return.",
|
|
65
|
+
INVALID_NEW_USER_CALLBACK_URL: "As above, for the first-sign-in return.",
|
|
66
|
+
INVALID_ORIGIN: "The CSRF origin gate. An operator reads this while checking `trustedOrigins`.",
|
|
67
|
+
INVALID_REDIRECT_URL: "A redirect target outside the configured set.",
|
|
68
|
+
METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED: "A caller used the wrong verb on the session route.",
|
|
69
|
+
MISSING_OR_NULL_ORIGIN: "The other half of the origin gate, and the same audience.",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Every error code the given plugin set can raise, plus Better Auth's own base vocabulary. */
|
|
73
|
+
export function composedErrorCodes(plugins: readonly BetterAuthPlugin[]): string[] {
|
|
74
|
+
const codes = new Set(Object.keys(BASE_ERROR_CODES as Record<string, unknown>));
|
|
75
|
+
for (const plugin of plugins) {
|
|
76
|
+
const own = (plugin as { $ERROR_CODES?: Record<string, unknown> }).$ERROR_CODES;
|
|
77
|
+
for (const code of Object.keys(own ?? {})) codes.add(code);
|
|
78
|
+
}
|
|
79
|
+
return [...codes].sort();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The dictionary the plugin is configured with: what it ships, with this kit's own words over the top.
|
|
84
|
+
*
|
|
85
|
+
* `en` is included and is the plugin's own, so a reader whose negotiated locale is English gets exactly
|
|
86
|
+
* the sentences Better Auth already wrote — this changes nothing for a project that serves one language.
|
|
87
|
+
*/
|
|
88
|
+
export function authErrorTranslations(): Record<string, Record<string, string>> {
|
|
89
|
+
const bundled = locales as unknown as Record<string, Record<string, string>>;
|
|
90
|
+
const merged: Record<string, Record<string, string>> = {};
|
|
91
|
+
for (const [locale, dictionary] of Object.entries(bundled)) {
|
|
92
|
+
merged[locale] = { ...dictionary, ...(KIT_OVERRIDES[locale] ?? {}) };
|
|
93
|
+
}
|
|
94
|
+
// A locale this kit writes that the plugin does not ship at all still has to reach the reader.
|
|
95
|
+
for (const [locale, dictionary] of Object.entries(KIT_OVERRIDES)) {
|
|
96
|
+
if (!merged[locale]) merged[locale] = { ...dictionary };
|
|
97
|
+
}
|
|
98
|
+
return merged;
|
|
99
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The package entrypoint — the surface `pithy add auth` wires into `pithy.config.ts`. Deliberately
|
|
6
|
+
* narrow: the capability factory, the `requireAuth()` gate other capabilities stack, and the data /
|
|
7
|
+
* secret types an app or tool needs. Every other module is imported by deep path
|
|
8
|
+
* (`@pithy-sh/auth/src/...`) — this is the documented contract, not a barrel over the package.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { AuthAuditActions } from "./audit/actions";
|
|
12
|
+
export { type AuthCapability, AuthConfig, type AuthConfigInput, auth, isAuthCapability } from "./capability";
|
|
13
|
+
export { type Device, DevicePlatform } from "./data/device";
|
|
14
|
+
export { type AuthDatabase, authDatabase } from "./data/tables";
|
|
15
|
+
export { DEVICE_HEADERS, type DeviceMeta } from "./device/registry";
|
|
16
|
+
export { requireAuth } from "./http/middleware";
|
|
17
|
+
export {
|
|
18
|
+
AppleOAuthCredentials,
|
|
19
|
+
AUTH_APPLE_CREDENTIALS,
|
|
20
|
+
AUTH_GOOGLE_CREDENTIALS,
|
|
21
|
+
AUTH_SESSION_SECRET,
|
|
22
|
+
authSecretsRegistry,
|
|
23
|
+
GoogleOAuthCredentials,
|
|
24
|
+
} from "./instance/secrets";
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
|
|
5
|
+
import { type BetterAuthPlugin, betterAuth } from "better-auth";
|
|
6
|
+
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
7
|
+
import { emitAfterRequest, emitProviderUnavailable } from "../audit/emit";
|
|
8
|
+
import { KIT_SESSION_FIELDS, KIT_USER_FIELDS } from "../data/kitFields";
|
|
9
|
+
import type { AuthDatabase } from "../data/tables";
|
|
10
|
+
import { parseDeviceMeta, registerDevice } from "../device/registry";
|
|
11
|
+
import { kitPlugins } from "./plugins";
|
|
12
|
+
import { providerUnavailable, type ResolvedProviders, unavailableProviderFor } from "./providers";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the `socialProviders` block from whichever provider credentials were resolved. Exported so the
|
|
16
|
+
* per-provider branch matrix is unit-testable without constructing a Better Auth instance — it reads
|
|
17
|
+
* only the resolved credentials on `deps`, never the database.
|
|
18
|
+
*
|
|
19
|
+
* **Only a `ready` provider is registered.** A `disabled` one never was; an `unresolvable` one is the
|
|
20
|
+
* change #381 made, and it is what keeps magic link and OTP working while one credential is unreadable.
|
|
21
|
+
* The narrowing is the type's doing rather than this function's discipline — `deps.google.credentials`
|
|
22
|
+
* does not exist until `state === "ready"` has been established.
|
|
23
|
+
*/
|
|
24
|
+
export function socialProviders(deps: AuthInstanceDeps): Record<string, unknown> | undefined {
|
|
25
|
+
const providers: Record<string, unknown> = {};
|
|
26
|
+
if (deps.google.state === "ready") {
|
|
27
|
+
providers.google = {
|
|
28
|
+
clientId: deps.google.credentials.clientId,
|
|
29
|
+
clientSecret: deps.google.credentials.clientSecret,
|
|
30
|
+
accessType: "offline",
|
|
31
|
+
prompt: "select_account consent",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (deps.apple.state === "ready") {
|
|
35
|
+
const apple = deps.apple.credentials;
|
|
36
|
+
providers.apple = {
|
|
37
|
+
clientId: apple.clientId,
|
|
38
|
+
clientSecret: apple.clientSecret,
|
|
39
|
+
...(apple.appBundleIdentifier ? { appBundleIdentifier: apple.appBundleIdentifier } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (deps.facebook.state === "ready") {
|
|
43
|
+
// Assert Facebook's email as verified. Facebook confirms a user's email before it will return it,
|
|
44
|
+
// and Better Auth validates the access token against this app before trusting the `/me` profile —
|
|
45
|
+
// so the email is genuinely the authenticated user's, verified by Facebook (the same trust Google
|
|
46
|
+
// and Apple get). Better Auth otherwise defaults Facebook's `emailVerified` to `false` (its OAuth
|
|
47
|
+
// response carries no `email_verified` claim, and the Graph API exposes no such field), which would
|
|
48
|
+
// wrongly route every Facebook sign-in through verify-to-link. `mapProfileToUser` overrides only
|
|
49
|
+
// Facebook's own email; Facebook stays out of `trustedProviders`.
|
|
50
|
+
providers.facebook = {
|
|
51
|
+
clientId: deps.facebook.credentials.clientId,
|
|
52
|
+
clientSecret: deps.facebook.credentials.clientSecret,
|
|
53
|
+
scope: ["email"],
|
|
54
|
+
mapProfileToUser: () => ({ emailVerified: true }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (deps.github.state === "ready") {
|
|
58
|
+
// `user:email` (Better Auth's default GitHub scope, requested explicitly here) lets the provider
|
|
59
|
+
// read the primary email's verified flag from the GitHub emails API — the signal account-linking
|
|
60
|
+
// gates on. GitHub is not a trusted provider, so an unverified GitHub email never auto-links.
|
|
61
|
+
providers.github = {
|
|
62
|
+
clientId: deps.github.credentials.clientId,
|
|
63
|
+
clientSecret: deps.github.credentials.clientSecret,
|
|
64
|
+
scope: ["user:email"],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return Object.keys(providers).length > 0 ? providers : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The seeding guard: a new account may be created only for an email a provider has **verified**.
|
|
72
|
+
*
|
|
73
|
+
* Passwordless sign-up (magic link / OTP) always creates `emailVerified: true`; a social sign-up
|
|
74
|
+
* carries the provider's own flag. Refusing an unverified-email create closes the rival-account
|
|
75
|
+
* seeding vector — otherwise an untrusted provider (e.g. a GitHub account holding an *unverified*
|
|
76
|
+
* email at someone else's address) could mint a row that a later magic-link login by the true owner
|
|
77
|
+
* would inherit. Returns `true` when the create must be refused. Account *linking* into an existing
|
|
78
|
+
* user is handled separately by Better Auth's `trustedProviders` gate; this only guards creation.
|
|
79
|
+
*/
|
|
80
|
+
export function isUnverifiedSignup(user: { emailVerified?: boolean | null }): boolean {
|
|
81
|
+
return user.emailVerified !== true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What the instance hands to Pithy's email seam to deliver. The route never sends inline — the hook
|
|
86
|
+
* enqueues an `@pithy-sh/email` job (`magicLink`/`otp` template) which a Workflow delivers.
|
|
87
|
+
*/
|
|
88
|
+
export type AuthEmailMessage =
|
|
89
|
+
| { to: string; template: "magicLink"; token: string; url: string }
|
|
90
|
+
| { to: string; template: "otp"; code: string };
|
|
91
|
+
|
|
92
|
+
/** The email-delivery seam: enqueue (never send inline). Injected so the instance stays I/O-agnostic. */
|
|
93
|
+
export type SendAuthEmail = (message: AuthEmailMessage) => Promise<void>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Everything the Better-Auth instance needs, resolved per invocation from config + request env.
|
|
97
|
+
*
|
|
98
|
+
* Generic in the adopter's plugin tuple so the composed instance's type — and therefore its `$Infer`
|
|
99
|
+
* surface — reflects what was actually composed rather than only the kit's own.
|
|
100
|
+
*/
|
|
101
|
+
export interface AuthInstanceDeps<Plugins extends readonly BetterAuthPlugin[] = readonly BetterAuthPlugin[]>
|
|
102
|
+
extends ResolvedProviders {
|
|
103
|
+
/** The shared Kysely over the `pithy_auth_*` tables (carries `CamelCasePlugin`). */
|
|
104
|
+
db: AuthDatabase;
|
|
105
|
+
/** The Better-Auth signing/encryption secret, sourced from `@pithy-sh/secrets`. */
|
|
106
|
+
secret: string;
|
|
107
|
+
/** The public base URL of this environment's auth worker (no trailing slash). */
|
|
108
|
+
baseURL: string;
|
|
109
|
+
/** The mount path; must equal the Hono route the handler is mounted under. */
|
|
110
|
+
basePath: string;
|
|
111
|
+
/** Web origins and mobile deep-link schemes allowed as OAuth/redirect targets and for CSRF origin checks. */
|
|
112
|
+
trustedOrigins: string[];
|
|
113
|
+
// The four social providers arrive from `ResolvedProviders` — each one `disabled`, `ready` with its
|
|
114
|
+
// credentials, or `unresolvable`. They are the one part of this interface that is deliberately not a
|
|
115
|
+
// precondition: `db` and `secret` above fail the whole instance, and a provider does not (#381).
|
|
116
|
+
/** Deliver a magic link or OTP. Enqueues an email job; never sends inline. */
|
|
117
|
+
sendEmail: SendAuthEmail;
|
|
118
|
+
/**
|
|
119
|
+
* The catalog locale this request negotiated, or `null` when nothing did.
|
|
120
|
+
*
|
|
121
|
+
* Threaded through to the translator plugin, which is what puts Better Auth's own refusals in the
|
|
122
|
+
* reader's language (#452). An instance is built per request, so this is that request's locale.
|
|
123
|
+
*/
|
|
124
|
+
locale?: string | null;
|
|
125
|
+
/** Session lifetime in seconds. */
|
|
126
|
+
sessionExpiresIn: number;
|
|
127
|
+
/** How often (seconds) an active session's expiry slides forward. */
|
|
128
|
+
sessionUpdateAge: number;
|
|
129
|
+
/** Magic-link / OTP token lifetime in seconds. */
|
|
130
|
+
verificationExpiresIn: number;
|
|
131
|
+
/** OTP length (digits). */
|
|
132
|
+
otpLength: number;
|
|
133
|
+
/** When true, sign-in never provisions a new user (existing accounts only). */
|
|
134
|
+
disableSignUp: boolean;
|
|
135
|
+
/** Audit seam — emits `auth/*` events. A no-op when the audit capability is absent. */
|
|
136
|
+
emit: AuditEmit;
|
|
137
|
+
/**
|
|
138
|
+
* The adopter's additional Better Auth plugins, from `auth({ plugins: [...] })`. Composed **after**
|
|
139
|
+
* the kit's own, never in place of one — `assertAdditivePlugins` has already refused a list that
|
|
140
|
+
* names one of them.
|
|
141
|
+
*/
|
|
142
|
+
plugins: Plugins;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The concrete return type of `makeAuth` — the Better-Auth instance with Pithy's plugin set, and the
|
|
147
|
+
* adopter's on top of it.
|
|
148
|
+
*
|
|
149
|
+
* Parameterized in the plugin tuple so an adopter can name the instance their own composition produces:
|
|
150
|
+
* `AuthInstance<[ReturnType<typeof organization>]>`. That is the type
|
|
151
|
+
* `inferAdditionalFields<…>()` needs on the client, and the reason the plugin tuple is threaded through
|
|
152
|
+
* `makeAuth` rather than widened to `BetterAuthPlugin[]` at the door.
|
|
153
|
+
*/
|
|
154
|
+
export type AuthInstance<Plugins extends readonly BetterAuthPlugin[] = readonly BetterAuthPlugin[]> = ReturnType<
|
|
155
|
+
typeof makeAuth<Plugins>
|
|
156
|
+
>;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Build the Better-Auth instance for one request.
|
|
160
|
+
*
|
|
161
|
+
* Passwordless only — `emailAndPassword` is never enabled. The Kysely adapter wraps our shared
|
|
162
|
+
* `CamelCasePlugin` instance, so Better Auth's camelCase model names + fields map to the snake_case
|
|
163
|
+
* `pithy_auth_*` columns the migration created. Dates are ISO-8601 text on SQLite; ids are WebCrypto
|
|
164
|
+
* UUIDs; rate limiting is durable (D1-backed) since memory limiting is per-isolate on Workers.
|
|
165
|
+
*/
|
|
166
|
+
export function makeAuth<const Plugins extends readonly BetterAuthPlugin[]>(deps: AuthInstanceDeps<Plugins>) {
|
|
167
|
+
return betterAuth({
|
|
168
|
+
appName: "Pithy",
|
|
169
|
+
baseURL: deps.baseURL,
|
|
170
|
+
basePath: deps.basePath,
|
|
171
|
+
secret: deps.secret,
|
|
172
|
+
telemetry: { enabled: false },
|
|
173
|
+
trustedOrigins: deps.trustedOrigins,
|
|
174
|
+
database: { db: deps.db, type: "sqlite", transaction: false },
|
|
175
|
+
advanced: {
|
|
176
|
+
// WebCrypto UUID ids for every model — anti-enumeration, Workers-safe.
|
|
177
|
+
database: { generateId: () => crypto.randomUUID() },
|
|
178
|
+
},
|
|
179
|
+
// Better Auth's errors bubble out of `handler` so the Hono boundary maps them to PithyError.
|
|
180
|
+
//
|
|
181
|
+
// **Load-bearing, and measured rather than assumed (#385).** The `before` hook below throws a
|
|
182
|
+
// `PithyError`, which better-call's router does not recognize as its own `APIError` — so without
|
|
183
|
+
// this it takes the default branch: `console.error("# SERVER_ERROR: ", error)`, which prints the
|
|
184
|
+
// whole payload including the `action` naming `auth-github-credentials`, and answers a bodyless
|
|
185
|
+
// 500. Removing it reddens two cases in `http/providerResolution.workers.test.ts` (503 becomes
|
|
186
|
+
// 500, and the caller's message disappears) and puts a secret name in the log. A handler could not
|
|
187
|
+
// replace it: `onAPIError.onError`'s return value is ignored, so the only way it reaches the same
|
|
188
|
+
// outcome is by throwing, which is what this is.
|
|
189
|
+
onAPIError: { throw: true },
|
|
190
|
+
databaseHooks: {
|
|
191
|
+
user: {
|
|
192
|
+
create: {
|
|
193
|
+
// Seeding guard: refuse to create a user whose email a provider has not verified. Every
|
|
194
|
+
// passwordless sign-up creates emailVerified=true and every trusted/verified social sign-up
|
|
195
|
+
// carries a verified flag, so only an untrusted+unverified social sign-up is blocked — the
|
|
196
|
+
// vector where a provider could seed a rival row at an address the caller doesn't own.
|
|
197
|
+
before: async (user) => {
|
|
198
|
+
if (isUnverifiedSignup(user)) {
|
|
199
|
+
throw new APIError("FORBIDDEN", {
|
|
200
|
+
code: "EMAIL_NOT_VERIFIED",
|
|
201
|
+
message:
|
|
202
|
+
"This email isn't verified by the provider. Sign in with a magic link to verify it, then connect the provider.",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
session: {
|
|
209
|
+
create: {
|
|
210
|
+
// Bind the session to its device (registering the device first, so the linkage is valid).
|
|
211
|
+
// Only fires when a request carried device headers — an internal rotation passes deviceId
|
|
212
|
+
// via override instead, so this hook leaves it untouched.
|
|
213
|
+
before: async (session, ctx) => {
|
|
214
|
+
const meta = ctx?.headers ? parseDeviceMeta(ctx.headers) : undefined;
|
|
215
|
+
if (!meta) return;
|
|
216
|
+
await registerDevice(deps.db, meta, {
|
|
217
|
+
userId: session.userId,
|
|
218
|
+
lastIp: session.ipAddress ?? null,
|
|
219
|
+
now: new Date(),
|
|
220
|
+
});
|
|
221
|
+
return { data: { ...session, deviceId: meta.id } };
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
hooks: {
|
|
227
|
+
/**
|
|
228
|
+
* Refuse a provider this deployment enables and could not resolve, before Better Auth answers it
|
|
229
|
+
* with the 404 it gives a provider nobody configured (#381).
|
|
230
|
+
*
|
|
231
|
+
* **This is what makes degrading not the same as degrading quietly.** The instance was built
|
|
232
|
+
* without the provider, so `socialProviders` does not hold it, so `sign-in/social` would throw
|
|
233
|
+
* `PROVIDER_NOT_FOUND` — a 404 that tells somebody who signs in with GitHub every day that
|
|
234
|
+
* GitHub was never set up. It is the same answer for a fault and for a choice, and the two are
|
|
235
|
+
* not the same fact. This hook answers 503 with its own code instead, and records the attempt.
|
|
236
|
+
*
|
|
237
|
+
* **A `before` hook rather than a Hono route, and that is forced rather than preferred.** The
|
|
238
|
+
* provider is in the request *body*, and the body is Better Auth's to read: a Hono handler ahead
|
|
239
|
+
* of the catch-all would have to consume the stream that the catch-all then hands to
|
|
240
|
+
* `instance.handler(c.req.raw)`. Here the body is already parsed against the endpoint's own
|
|
241
|
+
* schema, and this reads one field out of it.
|
|
242
|
+
*/
|
|
243
|
+
before: createAuthMiddleware(async (ctx) => {
|
|
244
|
+
const provider = unavailableProviderFor(ctx.path ?? "", ctx.body, deps);
|
|
245
|
+
if (!provider) return;
|
|
246
|
+
// Recorded before the throw, so the trail holds the attempt whether or not anything logs the
|
|
247
|
+
// refusal. `emitProviderUnavailable` swallows its own failure by contract.
|
|
248
|
+
await emitProviderUnavailable(deps.emit, { provider, headers: ctx.headers });
|
|
249
|
+
throw providerUnavailable(provider);
|
|
250
|
+
}),
|
|
251
|
+
// Emit audit events for every completed auth request: sign-in (+device) from the new session,
|
|
252
|
+
// plus the send/sign-out/token/OAuth events by path. Endpoint-scoped, so a rotation never emits.
|
|
253
|
+
after: createAuthMiddleware(async (ctx) => {
|
|
254
|
+
const newSession = ctx.context.newSession;
|
|
255
|
+
await emitAfterRequest(deps.emit, {
|
|
256
|
+
path: ctx.path ?? "",
|
|
257
|
+
headers: ctx.headers,
|
|
258
|
+
newSession: newSession
|
|
259
|
+
? {
|
|
260
|
+
userId: newSession.user.id,
|
|
261
|
+
sessionId: newSession.session.id,
|
|
262
|
+
deviceId: (newSession.session as { deviceId?: string | null }).deviceId ?? null,
|
|
263
|
+
}
|
|
264
|
+
: null,
|
|
265
|
+
currentUserId: ctx.context.session?.user?.id ?? null,
|
|
266
|
+
});
|
|
267
|
+
}),
|
|
268
|
+
},
|
|
269
|
+
rateLimit: {
|
|
270
|
+
// Memory limiting is per-isolate (useless on Workers); back it with the durable D1 table.
|
|
271
|
+
enabled: true,
|
|
272
|
+
storage: "database",
|
|
273
|
+
modelName: "pithyAuthRateLimit",
|
|
274
|
+
},
|
|
275
|
+
user: {
|
|
276
|
+
modelName: "pithyAuthUsers",
|
|
277
|
+
// One declaration, shared with the schema baseline in `../migrations/pluginTables.ts`. See
|
|
278
|
+
// `../data/kitFields.ts` for why a column missing here is invisible to Better Auth, and why the
|
|
279
|
+
// two used to be written out twice.
|
|
280
|
+
additionalFields: KIT_USER_FIELDS,
|
|
281
|
+
},
|
|
282
|
+
session: {
|
|
283
|
+
modelName: "pithyAuthSessions",
|
|
284
|
+
expiresIn: deps.sessionExpiresIn,
|
|
285
|
+
updateAge: deps.sessionUpdateAge,
|
|
286
|
+
// Server-set session fields clients never supply: the bound device, and the refresh-token family
|
|
287
|
+
// (carried across rotations via createSession override, like deviceId — see `token/rotation.ts`).
|
|
288
|
+
additionalFields: KIT_SESSION_FIELDS,
|
|
289
|
+
},
|
|
290
|
+
account: {
|
|
291
|
+
modelName: "pithyAuthAccounts",
|
|
292
|
+
accountLinking: {
|
|
293
|
+
enabled: true,
|
|
294
|
+
// Link a social sign-in to an existing magic-link user when the verified emails match.
|
|
295
|
+
trustedProviders: ["google", "apple"],
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
verification: { modelName: "pithyAuthVerifications" },
|
|
299
|
+
...((): { socialProviders?: Record<string, unknown> } => {
|
|
300
|
+
const providers = socialProviders(deps);
|
|
301
|
+
return providers ? { socialProviders: providers } : {};
|
|
302
|
+
})(),
|
|
303
|
+
// The kit's four first, the adopter's after. Order is the contract: Better Auth merges plugin
|
|
304
|
+
// endpoints by id and a later registration wins, so composing the adopter's list first would let
|
|
305
|
+
// it quietly redefine the sign-in this product promises. `assertAdditivePlugins` has already
|
|
306
|
+
// refused a list that names one of the four; this order is what makes that refusal the only way in.
|
|
307
|
+
plugins: [...kitPlugins(deps), ...deps.plugins],
|
|
308
|
+
});
|
|
309
|
+
}
|