@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,223 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
|
|
5
|
+
import { AuthAuditActions } from "./actions";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Audit emission helpers, kept out of the instance so the hook wiring stays legible. Every helper goes
|
|
9
|
+
* through core's `emit` seam (a no-op when audit is absent) and swallows its own failure — an audit
|
|
10
|
+
* write must never break the auth action it records.
|
|
11
|
+
*
|
|
12
|
+
* Sign-in is emitted from the endpoint `after` hook (where `newSession` is set), not the DB hook — so
|
|
13
|
+
* the internal `createSession` a token rotation performs never looks like a fresh sign-in.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Pull request correlation from hook headers (no PII beyond ip/user-agent). */
|
|
17
|
+
export function correlation(headers: Headers | undefined): { ip?: string; userAgent?: string } {
|
|
18
|
+
return {
|
|
19
|
+
ip: headers?.get("cf-connecting-ip") ?? undefined,
|
|
20
|
+
userAgent: headers?.get("user-agent") ?? undefined,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function safeEmit(emit: AuditEmit, event: Parameters<AuditEmit>[0]): Promise<void> {
|
|
25
|
+
try {
|
|
26
|
+
await emit(event);
|
|
27
|
+
} catch {
|
|
28
|
+
// An audit write is non-fatal by contract.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The session just created on a sign-in endpoint, plus the (already-authenticated) caller, if any. */
|
|
33
|
+
export interface AfterRequest {
|
|
34
|
+
path: string;
|
|
35
|
+
headers: Headers | undefined;
|
|
36
|
+
newSession: { userId: string; sessionId: string; deviceId: string | null } | null;
|
|
37
|
+
currentUserId: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pathAction(path: string): string | undefined {
|
|
41
|
+
if (path === "/sign-in/magic-link") return AuthAuditActions.magicLinkSent;
|
|
42
|
+
if (path === "/email-otp/send-verification-otp") return AuthAuditActions.otpSent;
|
|
43
|
+
if (path === "/sign-out") return AuthAuditActions.signout;
|
|
44
|
+
if (path === "/token") return AuthAuditActions.tokenRefresh;
|
|
45
|
+
// Only an explicit `/link-social` is a linking event. A `/callback/*` is an OAuth *sign-in* (already
|
|
46
|
+
// recorded as `auth/signin` from the new session) — mapping it here would mislabel every first
|
|
47
|
+
// sign-up as a link.
|
|
48
|
+
if (path === "/link-social") return AuthAuditActions.oauthLinked;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Emit the audit events for a completed auth request: sign-in (+device) from a new session, then any path event. */
|
|
53
|
+
export async function emitAfterRequest(emit: AuditEmit, req: AfterRequest): Promise<void> {
|
|
54
|
+
const corr = correlation(req.headers);
|
|
55
|
+
if (req.newSession) {
|
|
56
|
+
await safeEmit(emit, {
|
|
57
|
+
action: AuthAuditActions.signin,
|
|
58
|
+
outcome: "success",
|
|
59
|
+
actorType: "user",
|
|
60
|
+
actorId: req.newSession.userId,
|
|
61
|
+
sessionId: req.newSession.sessionId,
|
|
62
|
+
...corr,
|
|
63
|
+
});
|
|
64
|
+
if (req.newSession.deviceId) {
|
|
65
|
+
await safeEmit(emit, {
|
|
66
|
+
action: AuthAuditActions.deviceRegistered,
|
|
67
|
+
outcome: "success",
|
|
68
|
+
actorType: "user",
|
|
69
|
+
actorId: req.newSession.userId,
|
|
70
|
+
sessionId: req.newSession.sessionId,
|
|
71
|
+
...corr,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const action = pathAction(req.path);
|
|
76
|
+
if (action) {
|
|
77
|
+
const actorId = req.newSession?.userId ?? req.currentUserId ?? undefined;
|
|
78
|
+
await safeEmit(emit, {
|
|
79
|
+
action,
|
|
80
|
+
outcome: "success",
|
|
81
|
+
actorType: actorId ? "user" : "anonymous",
|
|
82
|
+
actorId,
|
|
83
|
+
...corr,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Emit a `token_refresh` event for a session rotation (the custom rotate route). */
|
|
89
|
+
export async function emitTokenRefresh(
|
|
90
|
+
emit: AuditEmit,
|
|
91
|
+
context: { userId: string; sessionId: string; ip?: string; userAgent?: string },
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
await safeEmit(emit, {
|
|
94
|
+
action: AuthAuditActions.tokenRefresh,
|
|
95
|
+
outcome: "success",
|
|
96
|
+
actorType: "user",
|
|
97
|
+
actorId: context.userId,
|
|
98
|
+
sessionId: context.sessionId,
|
|
99
|
+
ip: context.ip,
|
|
100
|
+
userAgent: context.userAgent,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Emit a `token_reuse_detected` event — a replayed refresh token was caught and its family revoked.
|
|
106
|
+
* Recorded as `denied` and attributed to the compromised account (the family's owner), so the security
|
|
107
|
+
* trail names whose family was revoked, not the anonymous replayer.
|
|
108
|
+
*/
|
|
109
|
+
export async function emitTokenReuseDetected(
|
|
110
|
+
emit: AuditEmit,
|
|
111
|
+
context: { userId: string; familyId: string; ip?: string; userAgent?: string },
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
await safeEmit(emit, {
|
|
114
|
+
action: AuthAuditActions.tokenReuseDetected,
|
|
115
|
+
outcome: "denied",
|
|
116
|
+
severity: "critical",
|
|
117
|
+
actorType: "user",
|
|
118
|
+
actorId: context.userId,
|
|
119
|
+
ip: context.ip,
|
|
120
|
+
userAgent: context.userAgent,
|
|
121
|
+
metadata: { familyId: context.familyId },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Emit a `device_revoked` event. */
|
|
126
|
+
export async function emitDeviceRevoked(
|
|
127
|
+
emit: AuditEmit,
|
|
128
|
+
context: { userId: string; ip?: string; userAgent?: string },
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
await safeEmit(emit, {
|
|
131
|
+
action: AuthAuditActions.deviceRevoked,
|
|
132
|
+
outcome: "success",
|
|
133
|
+
actorType: "user",
|
|
134
|
+
actorId: context.userId,
|
|
135
|
+
ip: context.ip,
|
|
136
|
+
userAgent: context.userAgent,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Emit one control-plane admin action.
|
|
142
|
+
*
|
|
143
|
+
* `actorType` is `control-plane` and never `user`: the caller holds no session and owns no user row, so
|
|
144
|
+
* recording it as a user would make "what did the management client do" unanswerable from the trail —
|
|
145
|
+
* and would put a dashboard operator's id in the same column as the adopter's own customers.
|
|
146
|
+
*
|
|
147
|
+
* `actorId` is the token's verified `sub` — *which person at the dashboard*, not merely which
|
|
148
|
+
* dashboard. `resourceId` is the user or session acted on, so the trail reads from both ends: every
|
|
149
|
+
* action one operator took, and everything ever done to one customer.
|
|
150
|
+
*
|
|
151
|
+
* No email address, no session token, no device push token reaches `metadata`. The trail is queryable
|
|
152
|
+
* and long-lived, and a management client that already saw the address does not need it copied into a
|
|
153
|
+
* second store with a different retention policy.
|
|
154
|
+
*/
|
|
155
|
+
export async function emitControlPlaneAction(
|
|
156
|
+
emit: AuditEmit,
|
|
157
|
+
event: {
|
|
158
|
+
action: string;
|
|
159
|
+
subject: string;
|
|
160
|
+
connectionId: string;
|
|
161
|
+
resourceType: string;
|
|
162
|
+
resourceId?: string | null;
|
|
163
|
+
ip?: string;
|
|
164
|
+
userAgent?: string;
|
|
165
|
+
requestId?: string;
|
|
166
|
+
metadata?: Record<string, unknown>;
|
|
167
|
+
},
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
await safeEmit(emit, {
|
|
170
|
+
action: event.action,
|
|
171
|
+
outcome: "success",
|
|
172
|
+
actorType: "control-plane",
|
|
173
|
+
actorId: event.subject,
|
|
174
|
+
resourceType: event.resourceType,
|
|
175
|
+
resourceId: event.resourceId ?? null,
|
|
176
|
+
ip: event.ip,
|
|
177
|
+
userAgent: event.userAgent,
|
|
178
|
+
requestId: event.requestId,
|
|
179
|
+
metadata: { connectionId: event.connectionId, ...event.metadata },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Emit a `provider_unavailable` event — somebody asked for a sign-in method this deployment enables and
|
|
185
|
+
* could not serve.
|
|
186
|
+
*
|
|
187
|
+
* `anonymous`, because the caller has no session yet by definition. `denied` rather than `failure`: the
|
|
188
|
+
* request was refused deliberately by a rule this Worker holds, not lost to a fault. `warning` rather
|
|
189
|
+
* than `critical` — the other sign-in methods are working, which is the entire point of #381, so this is
|
|
190
|
+
* notable rather than alert-worthy.
|
|
191
|
+
*
|
|
192
|
+
* `metadata.provider` is the provider id and nothing else. The secret name is derivable from it and is
|
|
193
|
+
* still not written: an audit row is long-lived and queryable, and naming a store entry in one is a map
|
|
194
|
+
* for whoever reads the trail later.
|
|
195
|
+
*/
|
|
196
|
+
export async function emitProviderUnavailable(
|
|
197
|
+
emit: AuditEmit,
|
|
198
|
+
context: { provider: string; headers: Headers | undefined },
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
await safeEmit(emit, {
|
|
201
|
+
action: AuthAuditActions.providerUnavailable,
|
|
202
|
+
outcome: "denied",
|
|
203
|
+
severity: "warning",
|
|
204
|
+
actorType: "anonymous",
|
|
205
|
+
metadata: { provider: context.provider },
|
|
206
|
+
...correlation(context.headers),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A blocked/failed auth attempt — recorded as `denied` (first-class). */
|
|
211
|
+
export async function emitDenied(
|
|
212
|
+
emit: AuditEmit,
|
|
213
|
+
context: { ip?: string; userAgent?: string; detail?: string },
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
await safeEmit(emit, {
|
|
216
|
+
action: AuthAuditActions.signin,
|
|
217
|
+
outcome: "denied",
|
|
218
|
+
actorType: "anonymous",
|
|
219
|
+
ip: context.ip,
|
|
220
|
+
userAgent: context.userAgent,
|
|
221
|
+
metadata: context.detail ? { reason: context.detail } : undefined,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability, PithyMiddleware } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { defineCapability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import type { EmailCapability } from "@pithy-sh/email/src/capability";
|
|
8
|
+
import { isEmailCapability } from "@pithy-sh/email/src/capability";
|
|
9
|
+
import { isTurnstileCapability } from "@pithy-sh/turnstile/src/capability";
|
|
10
|
+
import { TURNSTILE_LOGIN_ACTION, type TurnstileMode } from "@pithy-sh/turnstile/src/config/config";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import type { AuthClientProjection } from "./client/projection";
|
|
13
|
+
import { authTables } from "./data/tables";
|
|
14
|
+
import { publishSameOrigin } from "./http/csrf";
|
|
15
|
+
import { registerDevLoginRoute } from "./http/devLoginRoute";
|
|
16
|
+
import { authAdminRoutes } from "./http/guards";
|
|
17
|
+
import { createSessionMiddleware } from "./http/middleware";
|
|
18
|
+
import { createRateLimitMiddleware } from "./http/rateLimit";
|
|
19
|
+
import { createAuthRoutes } from "./http/routes";
|
|
20
|
+
import { AuthPlugin, assertAdditivePlugins } from "./instance/plugins";
|
|
21
|
+
import { authSecretsRegistry } from "./instance/secrets";
|
|
22
|
+
import { AUTH_MIGRATION_ORDER, auth_0001_init } from "./migrations/0001_init";
|
|
23
|
+
import { authPluginPlan } from "./migrations/pluginTables";
|
|
24
|
+
import { authDevSessionSeed } from "./seeds/devSession";
|
|
25
|
+
import { authExampleSeed } from "./seeds/example";
|
|
26
|
+
import { PACKAGE_VERSION } from "./version.generated";
|
|
27
|
+
|
|
28
|
+
/** A social provider toggle. Credentials live in the secrets store, never config. */
|
|
29
|
+
const ProviderToggle = z
|
|
30
|
+
.object({
|
|
31
|
+
enabled: z
|
|
32
|
+
.boolean()
|
|
33
|
+
.default(false)
|
|
34
|
+
.describe("Whether this social provider is enabled. Credentials are read from the secrets store, never config."),
|
|
35
|
+
})
|
|
36
|
+
.describe("A social provider's on/off toggle.");
|
|
37
|
+
|
|
38
|
+
/** The auth capability's configuration — the thin surface an adopter owns in `pithy.config.ts`. */
|
|
39
|
+
export const AuthConfig = z
|
|
40
|
+
.object({
|
|
41
|
+
basePath: z
|
|
42
|
+
.string()
|
|
43
|
+
.default("/auth")
|
|
44
|
+
.describe(
|
|
45
|
+
"The path the auth handler mounts under. Must match the OAuth redirect URIs you register. Defaults to `/auth`.",
|
|
46
|
+
),
|
|
47
|
+
baseURL: z
|
|
48
|
+
.string()
|
|
49
|
+
.describe(
|
|
50
|
+
"The public origin of this worker where it is deployed (no trailing slash). OAuth callbacks, JWKS and magic-link URLs are built from it. A `dev` composition ignores it and serves on `http://<the host the request arrived at>` instead — local dev has no TLS and its port is assigned per run, so it is the one address nobody can write down.",
|
|
51
|
+
),
|
|
52
|
+
trustedOrigins: z
|
|
53
|
+
.array(z.string())
|
|
54
|
+
.default([])
|
|
55
|
+
.describe(
|
|
56
|
+
"Web origins and mobile deep-link schemes allowed as redirect targets and for cookie CSRF origin checks (e.g. `https://app.example.com`, `myapp://`).",
|
|
57
|
+
),
|
|
58
|
+
database: z
|
|
59
|
+
.string()
|
|
60
|
+
.default("DB")
|
|
61
|
+
.describe("The D1 binding the auth tables and migrations target. Defaults to `DB`, the shared app database."),
|
|
62
|
+
rateLimiterBinding: z
|
|
63
|
+
.string()
|
|
64
|
+
.default("AUTH_RATE_LIMITER")
|
|
65
|
+
.describe(
|
|
66
|
+
"The Workers Rate Limiting binding for the coarse per-IP edge guard on the auth routes (tier 1). Its limit and window are set on the binding in wrangler.jsonc. Complements Better Auth's per-action limiter (tier 2).",
|
|
67
|
+
),
|
|
68
|
+
google: ProviderToggle.default({ enabled: false }).describe(
|
|
69
|
+
"Google OAuth. Enable it, then store credentials as the `auth-google-credentials` secret. See docs/google-oauth.md.",
|
|
70
|
+
),
|
|
71
|
+
apple: ProviderToggle.default({ enabled: false }).describe(
|
|
72
|
+
"Apple Sign-In. Enable it, then store credentials as the `auth-apple-credentials` secret. See docs/apple-signin.md.",
|
|
73
|
+
),
|
|
74
|
+
facebook: ProviderToggle.default({ enabled: false }).describe(
|
|
75
|
+
"Facebook Login. Enable it, then store credentials as the `auth-facebook-credentials` secret. See docs/facebook-oauth.md.",
|
|
76
|
+
),
|
|
77
|
+
github: ProviderToggle.default({ enabled: false }).describe(
|
|
78
|
+
"GitHub OAuth. Enable it, then store credentials as the `auth-github-credentials` secret. See docs/github-oauth.md.",
|
|
79
|
+
),
|
|
80
|
+
sessionExpiresIn: z
|
|
81
|
+
.number()
|
|
82
|
+
.int()
|
|
83
|
+
.default(60 * 60 * 24 * 7)
|
|
84
|
+
.describe("Session (refresh credential) lifetime in seconds. Defaults to 7 days. Expiry slides forward on use."),
|
|
85
|
+
sessionUpdateAge: z
|
|
86
|
+
.number()
|
|
87
|
+
.int()
|
|
88
|
+
.default(60 * 60 * 24)
|
|
89
|
+
.describe("How often (seconds) an active session's expiry slides forward. Defaults to 1 day."),
|
|
90
|
+
verificationExpiresIn: z
|
|
91
|
+
.number()
|
|
92
|
+
.int()
|
|
93
|
+
.default(300)
|
|
94
|
+
.describe("Magic-link and OTP lifetime in seconds. Defaults to 5 minutes. Single-use regardless."),
|
|
95
|
+
otpLength: z.number().int().default(6).describe("The number of digits in an email OTP. Defaults to 6."),
|
|
96
|
+
disableSignUp: z
|
|
97
|
+
.boolean()
|
|
98
|
+
.default(false)
|
|
99
|
+
.describe(
|
|
100
|
+
"When true, sign-in never provisions a new user — existing accounts only. Unknown emails get no email (anti-enumeration).",
|
|
101
|
+
),
|
|
102
|
+
plugins: z
|
|
103
|
+
.array(AuthPlugin)
|
|
104
|
+
.default([])
|
|
105
|
+
.describe(
|
|
106
|
+
"Additional Better Auth plugins to compose — `organization()`, `passkey()`, `twoFactor()`, `apiKey()`, a generic OAuth provider. Additive: they join the set the kit composes (i18n, bearer, jwt, magic-link, email-otp) and cannot replace one. Tables a plugin declares are created by `pithy migrate`; add the matching client plugin to `createAuthClient` for its typed client surface.",
|
|
107
|
+
),
|
|
108
|
+
})
|
|
109
|
+
.describe("Configuration for the auth capability.");
|
|
110
|
+
export type AuthConfig = z.output<typeof AuthConfig>;
|
|
111
|
+
export type AuthConfigInput = z.input<typeof AuthConfig>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The runtime wiring the middleware and routes close over. `config` is known at `auth()` call time;
|
|
115
|
+
* `emailConfig` and `turnstile` are filled by `compose` from the composed peer capabilities, before
|
|
116
|
+
* any request runs.
|
|
117
|
+
*/
|
|
118
|
+
export interface AuthWiring {
|
|
119
|
+
config: AuthConfig;
|
|
120
|
+
/** The email capability's bound enqueue seam — how magic-link/OTP are delivered. Set by `compose`. */
|
|
121
|
+
enqueueEmail: EmailCapability["enqueue"] | undefined;
|
|
122
|
+
/** The turnstile login gate, when the turnstile capability is composed. Set by `compose`. */
|
|
123
|
+
turnstile: { mode: TurnstileMode } | undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The auth capability, with its resolved config attached for inspection. */
|
|
127
|
+
export interface AuthCapability extends Capability {
|
|
128
|
+
authConfig: AuthConfig;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Refuse a composition where a Better Auth plugin's table is one another capability already declares in
|
|
133
|
+
* the same database. `capabilities` is every capability composed into this backend, so this is the first
|
|
134
|
+
* moment the question can be asked at all — `auth()` sees only itself.
|
|
135
|
+
*/
|
|
136
|
+
function assertPluginTablesUnclaimed(
|
|
137
|
+
capabilities: readonly Capability[],
|
|
138
|
+
binding: string,
|
|
139
|
+
extensions: readonly { id: string; tables: string[] }[],
|
|
140
|
+
): void {
|
|
141
|
+
const claimed = new Map<string, string>();
|
|
142
|
+
for (const capability of capabilities) {
|
|
143
|
+
if (capability.name === "auth") continue;
|
|
144
|
+
for (const spec of Object.values(capability.databases ?? {})) {
|
|
145
|
+
if (spec.binding !== binding) continue;
|
|
146
|
+
for (const table of Object.keys(spec.tables)) claimed.set(table, capability.name);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const extension of extensions) {
|
|
150
|
+
for (const table of extension.tables) {
|
|
151
|
+
const owner = claimed.get(table);
|
|
152
|
+
if (!owner) continue;
|
|
153
|
+
throw new ValidationError({
|
|
154
|
+
message: `The Better Auth "${extension.id}" plugin and the ${owner} capability both use a ${table} table.`,
|
|
155
|
+
action: `Rename the plugin's through its own \`schema: { ${table}: { modelName: "…" } }\` option, or point auth at another database.`,
|
|
156
|
+
detail: `plugin "${extension.id}" table "${table}" is already declared by capability "${owner}" on binding ${binding}`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The auth capability. Passwordless sign-in (magic link, OTP, Google, Apple), the hybrid bearer +
|
|
164
|
+
* cookie token model, the per-device session registry, and the `bearer`/`session` fills for core's
|
|
165
|
+
* `AuthContext` seam. Depends on `secrets` and `email`; auto-gates its send routes with `turnstile`
|
|
166
|
+
* and emits `auth/*` through `audit` when those are composed.
|
|
167
|
+
*/
|
|
168
|
+
export function auth(config: AuthConfigInput): AuthCapability {
|
|
169
|
+
const resolved = AuthConfig.parse(config);
|
|
170
|
+
// Additivity, before anything is built from the list. The four the kit composes are the sign-in this
|
|
171
|
+
// product promises and what the control-plane seam verifies against, so a list naming one of them is
|
|
172
|
+
// refused here by name rather than silently redefining a route at request time.
|
|
173
|
+
assertAdditivePlugins(resolved.plugins);
|
|
174
|
+
// And the tables those plugins imply, derived now so a collision is a config error at `auth()` rather
|
|
175
|
+
// than a half-applied migration against a database with no transactional DDL.
|
|
176
|
+
const pluginPlan = authPluginPlan(resolved.plugins);
|
|
177
|
+
const wiring: AuthWiring = { config: resolved, enqueueEmail: undefined, turnstile: undefined };
|
|
178
|
+
|
|
179
|
+
// Tier-1 edge rate limiter, contributed as middleware so it runs before session resolution.
|
|
180
|
+
const rateLimitMiddleware: PithyMiddleware = (app) => {
|
|
181
|
+
app.use(`${resolved.basePath}/*`, createRateLimitMiddleware(resolved.rateLimiterBinding));
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const capability = defineCapability({
|
|
185
|
+
name: "auth",
|
|
186
|
+
// The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
|
|
187
|
+
// cannot read its own package.json. Reported per capability by the control-plane manifest.
|
|
188
|
+
version: PACKAGE_VERSION,
|
|
189
|
+
config: AuthConfig,
|
|
190
|
+
dependsOn: ["secrets", "email"],
|
|
191
|
+
secretRegistry: authSecretsRegistry,
|
|
192
|
+
requiredBindings: [
|
|
193
|
+
{ type: "d1", name: resolved.database },
|
|
194
|
+
{ type: "ratelimit", name: resolved.rateLimiterBinding },
|
|
195
|
+
],
|
|
196
|
+
// What the adopter plugged in, and what it brought with it. A composed plugin adds routes to this
|
|
197
|
+
// Worker and tables to this database while having no package.json for anything to read a name off,
|
|
198
|
+
// so without this line the only place it appears is the source of `pithy.config.ts`.
|
|
199
|
+
extensions: pluginPlan.extensions.map((extension) => ({
|
|
200
|
+
kind: "better-auth-plugin",
|
|
201
|
+
id: extension.id,
|
|
202
|
+
tables: extension.tables,
|
|
203
|
+
})),
|
|
204
|
+
databases: {
|
|
205
|
+
app: {
|
|
206
|
+
binding: resolved.database,
|
|
207
|
+
tables: authTables,
|
|
208
|
+
migrationOrder: AUTH_MIGRATION_ORDER,
|
|
209
|
+
// One namespace, one order. `AUTH_MIGRATION_ORDER` is stable forever — renumbering it would
|
|
210
|
+
// rename `0300_auth_0001_init` and re-run every applied auth migration.
|
|
211
|
+
//
|
|
212
|
+
// Beside the kit's own set: one derived migration per adopter plugin that declares a schema
|
|
213
|
+
// (`0002_plugin_<id>`). That is the whole answer to "a plugin brings tables" — the plugin list
|
|
214
|
+
// is in `pithy.config.ts`, which is the file `pithy migrate` already imports to collect
|
|
215
|
+
// capabilities, so the tables ride the migration model that exists rather than needing a new
|
|
216
|
+
// one. A project that composes no plugins contributes exactly what it did before.
|
|
217
|
+
migrations: { "0001_init": auth_0001_init, ...pluginPlan.migrations },
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
compose: ({ capabilities }) => {
|
|
221
|
+
const email = capabilities.find(isEmailCapability);
|
|
222
|
+
if (!email) {
|
|
223
|
+
throw new ValidationError({
|
|
224
|
+
message: "The auth capability requires the email capability.",
|
|
225
|
+
action: "Add `email(...)` to your capabilities — magic-link and OTP delivery enqueue email jobs.",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
wiring.enqueueEmail = email.enqueue;
|
|
229
|
+
// Auto-wire turnstile onto the login routes when it is composed (Jim's requirement: zero config).
|
|
230
|
+
const turnstileCap = capabilities.find(isTurnstileCapability);
|
|
231
|
+
const loginMode = turnstileCap?.turnstileConfig.protect[TURNSTILE_LOGIN_ACTION];
|
|
232
|
+
wiring.turnstile = loginMode ? { mode: loginMode } : undefined;
|
|
233
|
+
// And the one collision `auth()` could not see. A plugin's tables carry the plugin's own names —
|
|
234
|
+
// `organization`, `member`, `invitation` — with no `pithy_auth_` prefix to keep them out of an
|
|
235
|
+
// adopter's way, because they are the adopter's tables now. `composeDatabases` catches two
|
|
236
|
+
// capabilities claiming one table because both declared it; a plugin's tables are not in the
|
|
237
|
+
// declared map (they have no Zod schema), so this asks the same question of the composed set.
|
|
238
|
+
// Refused at boot, naming both sides: the alternative is a `create table` that fails halfway
|
|
239
|
+
// through `pithy migrate`, or two capabilities quietly reading and writing one table.
|
|
240
|
+
assertPluginTablesUnclaimed(capabilities, resolved.database, pluginPlan.extensions);
|
|
241
|
+
},
|
|
242
|
+
/**
|
|
243
|
+
* The client-safe projection — what a sign-in screen needs and nothing more: where to call
|
|
244
|
+
* (`basePath`), which buttons to render (the four provider toggles), how many OTP boxes to draw
|
|
245
|
+
* (`otpLength`), and whether to offer sign-up (`signUpEnabled`).
|
|
246
|
+
*
|
|
247
|
+
* Deliberately absent: `baseURL`, `trustedOrigins`, `database`, `rateLimiterBinding`, and every
|
|
248
|
+
* session/verification lifetime — a browser has no use for them and they describe the deployment.
|
|
249
|
+
* OAuth credentials cannot leak here because they are not in this config at all: they live in the
|
|
250
|
+
* secrets store (`authSecretsRegistry`), read only inside the Worker. That is what makes this
|
|
251
|
+
* projection provably safe — the sensitive values are not in reach of the function.
|
|
252
|
+
*
|
|
253
|
+
* The return type is {@link AuthClientProjection} — **declared, not inferred**. `Capability.client`
|
|
254
|
+
* types this as `{ enabled: boolean }` plus a JSON catchall, which accepts anything this literal
|
|
255
|
+
* could say. The declared type is what makes a dropped field — and a grown one, `baseURL` projected
|
|
256
|
+
* "just for a redirect" — a compile error here rather than a browser's problem.
|
|
257
|
+
*/
|
|
258
|
+
client: (): AuthClientProjection => ({
|
|
259
|
+
enabled: true,
|
|
260
|
+
basePath: resolved.basePath,
|
|
261
|
+
// Nested, so a screen can iterate the set rather than naming four booleans — and so adding a
|
|
262
|
+
// fifth provider is one key here, not a new top-level name every screen has to learn.
|
|
263
|
+
providers: {
|
|
264
|
+
google: resolved.google.enabled,
|
|
265
|
+
apple: resolved.apple.enabled,
|
|
266
|
+
facebook: resolved.facebook.enabled,
|
|
267
|
+
github: resolved.github.enabled,
|
|
268
|
+
},
|
|
269
|
+
otpLength: resolved.otpLength,
|
|
270
|
+
signUpEnabled: !resolved.disableSignUp,
|
|
271
|
+
}),
|
|
272
|
+
// Order matters: the same-origin policy is published first, so it is on the request before any
|
|
273
|
+
// route can gate on it; then the tier-1 edge rate limiter (before session resolution touches D1);
|
|
274
|
+
// then the session-resolution middleware fills the AuthContext.
|
|
275
|
+
middleware: [publishSameOrigin(wiring), rateLimitMiddleware, createSessionMiddleware(wiring)],
|
|
276
|
+
// The dev-login redirect goes on **first**, and the reason is `basePath`: it defaults to `/auth`,
|
|
277
|
+
// but an adopter may mount auth at the root, and Better Auth's catch-all (`${basePath}/*`) returns
|
|
278
|
+
// a Response, which ends the chain. Registered after it, `/__pithy/dev-login` would be a route the
|
|
279
|
+
// table shows and nothing ever reaches. It registers itself only in a `dev` composition that is not
|
|
280
|
+
// CI — see `http/devLoginRoute.ts` for the two gates and why they are two.
|
|
281
|
+
routes: (app) => {
|
|
282
|
+
registerDevLoginRoute(wiring)(app);
|
|
283
|
+
createAuthRoutes(wiring)(app);
|
|
284
|
+
},
|
|
285
|
+
// Built from the RESOLVED basePath, never the default: an adopter who mounts auth at `/identity`
|
|
286
|
+
// must get a manifest naming `/identity/admin/users`, or a management client composing its calls
|
|
287
|
+
// from the manifest 404s against exactly the adopters who customized anything.
|
|
288
|
+
adminRoutes: authAdminRoutes(resolved.basePath),
|
|
289
|
+
// Order matters here too: the dev-session set sorts last, after every set that could create the user
|
|
290
|
+
// it signs in as — this one's example cast included, and the adopter's own.
|
|
291
|
+
seeds: [authExampleSeed, authDevSessionSeed],
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return Object.assign(capability, { authConfig: resolved });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Type guard: is this capability the auth capability? */
|
|
298
|
+
export function isAuthCapability(capability: Capability): capability is AuthCapability {
|
|
299
|
+
return capability.name === "auth" && "authConfig" in capability;
|
|
300
|
+
}
|